diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala new file mode 100644 index 0000000..d2e0b4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag + +case class Bin[Source, +Value]( + label: String, + description: Option[String | HtmlElement], + color: ColorDef, + valueOf: Source => Value +): + def map[NewValue]( + f: Bin[Source, Value] => Source => NewValue + ): Bin[Source, NewValue] = + copy(valueOf = f(this)) + +case class Bins[T, U](bins: Seq[Bin[T, U]]): + def apply(v: T): Seq[U] = bins.map(_.valueOf(v)) + def map[A](r: Bin[T, U] => T => A): Bins[T, A] = + copy(bins = bins.map(_.map(r))) + + def renderSeparated( + r: Bin[T, U] => T => HtmlElement, + separator: => HtmlElement = span(" / "), + container: HtmlTag[org.scalajs.dom.html.Element] = span + )(v: T): HtmlElement = + container(interleave(map(r)(v), separator)) + + def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala new file mode 100644 index 0000000..d2e0b4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag + +case class Bin[Source, +Value]( + label: String, + description: Option[String | HtmlElement], + color: ColorDef, + valueOf: Source => Value +): + def map[NewValue]( + f: Bin[Source, Value] => Source => NewValue + ): Bin[Source, NewValue] = + copy(valueOf = f(this)) + +case class Bins[T, U](bins: Seq[Bin[T, U]]): + def apply(v: T): Seq[U] = bins.map(_.valueOf(v)) + def map[A](r: Bin[T, U] => T => A): Bins[T, A] = + copy(bins = bins.map(_.map(r))) + + def renderSeparated( + r: Bin[T, U] => T => HtmlElement, + separator: => HtmlElement = span(" / "), + container: HtmlTag[org.scalajs.dom.html.Element] = span + )(v: T): HtmlElement = + container(interleave(map(r)(v), separator)) + + def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala new file mode 100644 index 0000000..a47f5ba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala @@ -0,0 +1,45 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait ButtonComponentsModule: + def buttons: ButtonComponents + + trait ButtonComponents: + def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + +trait DefaultButtonComponentsModule(using ctx: ComponentContext) + extends ButtonComponentsModule: + + override val buttons = new ButtonComponents: + + private inline def srHelp(id: String): Modifier[HtmlElement] = + ctx.messages + .opt(s"form.button.${id}.screenReaderHelp") + .map(sr => span(cls := "sr-only", sr)) + + override def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srHelp(id), + icon + ) + + override def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srHelp(id) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala new file mode 100644 index 0000000..d2e0b4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag + +case class Bin[Source, +Value]( + label: String, + description: Option[String | HtmlElement], + color: ColorDef, + valueOf: Source => Value +): + def map[NewValue]( + f: Bin[Source, Value] => Source => NewValue + ): Bin[Source, NewValue] = + copy(valueOf = f(this)) + +case class Bins[T, U](bins: Seq[Bin[T, U]]): + def apply(v: T): Seq[U] = bins.map(_.valueOf(v)) + def map[A](r: Bin[T, U] => T => A): Bins[T, A] = + copy(bins = bins.map(_.map(r))) + + def renderSeparated( + r: Bin[T, U] => T => HtmlElement, + separator: => HtmlElement = span(" / "), + container: HtmlTag[org.scalajs.dom.html.Element] = span + )(v: T): HtmlElement = + container(interleave(map(r)(v), separator)) + + def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala new file mode 100644 index 0000000..a47f5ba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala @@ -0,0 +1,45 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait ButtonComponentsModule: + def buttons: ButtonComponents + + trait ButtonComponents: + def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + +trait DefaultButtonComponentsModule(using ctx: ComponentContext) + extends ButtonComponentsModule: + + override val buttons = new ButtonComponents: + + private inline def srHelp(id: String): Modifier[HtmlElement] = + ctx.messages + .opt(s"form.button.${id}.screenReaderHelp") + .map(sr => span(cls := "sr-only", sr)) + + override def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srHelp(id), + icon + ) + + override def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srHelp(id) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala new file mode 100644 index 0000000..15169f3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala @@ -0,0 +1,191 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom.html.UList +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind + +trait DashboardComponentsModule: + def dashboard: DashboardComponents + + // Only methods that create HtmlElements + trait DashboardComponents(using ComponentContext): + def section(label: String, children: Modifier[HtmlElement]): Div + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li + def cardInitials( + initials: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div + def count(label: String, value: Int, color: ColorDef): Span + def counts(children: HtmlElement*): Span + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + +trait DefaultDashboardComponentsModule(using ComponentContext) + extends DashboardComponentsModule: + override val dashboard: DashboardComponents = new DashboardComponents: + def section(label: String, children: Modifier[HtmlElement]): Div = + div( + cls("mt-3"), + h2( + cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), + label + ), + children + ) + + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = + ul( + cls( + "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + ), + children + ) + + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li = + li( + div( + cls("col-span-1 flex shadow-sm rounded-md"), + initials, + 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"), + div( + cls("text-gray-900 font-medium hover:text-gray-600 truncate"), + label + ), + p(cls("text-gray-500"), children) + ) + ) + ) + ) + + def cardInitials( + id: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div = + div( + cls( + "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" + ), + color.map(_(600).bg), + div(id), + children + ) + + def count(label: String, value: Int, color: ColorDef): Span = + span(color.text, title(label), s"${value}") + + def counts(children: HtmlElement*): Span = + span(interleave(children, span(" / "))) + + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div = + div( + cls("flex flex-col mt-2"), + div( + cls( + "align-middle min-w-full shadow sm:rounded-lg" + ), + L.table( + cls("min-w-full border-separate border-spacing-0"), + styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work + thead( + tr( + cls("border-t border-gray-200"), + headers.map(h => + th( + cls( + "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + h + ) + ) + ) + ), + tbody(cls("bg-white divide-y divide-gray-100"), sections) + ) + ) + ) + + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + td( + cls("bg-gray-50 border-gray-200 border-t border-b"), + cls( + "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + colSpan(cs), + label + ), + mods + ) + + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + mods, + td( + cls( + "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" + ), + label + ), + children.map(e => + td( + cls( + "whitespace-nowrap px-2 py-2 text-sm text-gray-500" + ), + e + ) + ) + ) + + private def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala new file mode 100644 index 0000000..d2e0b4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag + +case class Bin[Source, +Value]( + label: String, + description: Option[String | HtmlElement], + color: ColorDef, + valueOf: Source => Value +): + def map[NewValue]( + f: Bin[Source, Value] => Source => NewValue + ): Bin[Source, NewValue] = + copy(valueOf = f(this)) + +case class Bins[T, U](bins: Seq[Bin[T, U]]): + def apply(v: T): Seq[U] = bins.map(_.valueOf(v)) + def map[A](r: Bin[T, U] => T => A): Bins[T, A] = + copy(bins = bins.map(_.map(r))) + + def renderSeparated( + r: Bin[T, U] => T => HtmlElement, + separator: => HtmlElement = span(" / "), + container: HtmlTag[org.scalajs.dom.html.Element] = span + )(v: T): HtmlElement = + container(interleave(map(r)(v), separator)) + + def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala new file mode 100644 index 0000000..a47f5ba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala @@ -0,0 +1,45 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait ButtonComponentsModule: + def buttons: ButtonComponents + + trait ButtonComponents: + def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + +trait DefaultButtonComponentsModule(using ctx: ComponentContext) + extends ButtonComponentsModule: + + override val buttons = new ButtonComponents: + + private inline def srHelp(id: String): Modifier[HtmlElement] = + ctx.messages + .opt(s"form.button.${id}.screenReaderHelp") + .map(sr => span(cls := "sr-only", sr)) + + override def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srHelp(id), + icon + ) + + override def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srHelp(id) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala new file mode 100644 index 0000000..15169f3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala @@ -0,0 +1,191 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom.html.UList +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind + +trait DashboardComponentsModule: + def dashboard: DashboardComponents + + // Only methods that create HtmlElements + trait DashboardComponents(using ComponentContext): + def section(label: String, children: Modifier[HtmlElement]): Div + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li + def cardInitials( + initials: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div + def count(label: String, value: Int, color: ColorDef): Span + def counts(children: HtmlElement*): Span + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + +trait DefaultDashboardComponentsModule(using ComponentContext) + extends DashboardComponentsModule: + override val dashboard: DashboardComponents = new DashboardComponents: + def section(label: String, children: Modifier[HtmlElement]): Div = + div( + cls("mt-3"), + h2( + cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), + label + ), + children + ) + + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = + ul( + cls( + "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + ), + children + ) + + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li = + li( + div( + cls("col-span-1 flex shadow-sm rounded-md"), + initials, + 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"), + div( + cls("text-gray-900 font-medium hover:text-gray-600 truncate"), + label + ), + p(cls("text-gray-500"), children) + ) + ) + ) + ) + + def cardInitials( + id: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div = + div( + cls( + "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" + ), + color.map(_(600).bg), + div(id), + children + ) + + def count(label: String, value: Int, color: ColorDef): Span = + span(color.text, title(label), s"${value}") + + def counts(children: HtmlElement*): Span = + span(interleave(children, span(" / "))) + + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div = + div( + cls("flex flex-col mt-2"), + div( + cls( + "align-middle min-w-full shadow sm:rounded-lg" + ), + L.table( + cls("min-w-full border-separate border-spacing-0"), + styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work + thead( + tr( + cls("border-t border-gray-200"), + headers.map(h => + th( + cls( + "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + h + ) + ) + ) + ), + tbody(cls("bg-white divide-y divide-gray-100"), sections) + ) + ) + ) + + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + td( + cls("bg-gray-50 border-gray-200 border-t border-b"), + cls( + "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + colSpan(cs), + label + ), + mods + ) + + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + mods, + td( + cls( + "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" + ), + label + ), + children.map(e => + td( + cls( + "whitespace-nowrap px-2 py-2 text-sm text-gray-500" + ), + e + ) + ) + ) + + private def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala new file mode 100644 index 0000000..4cff836 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala @@ -0,0 +1,56 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +import zio.* +import com.raquo.airstream.core.Observer +import scala.annotation.implicitNotFound + +trait EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] + +object EffectHandler: + given zioEffectHandler[Env, E, A](using + Runtime[Env] + ): Conversion[ZIOEffectHandler[Env, E, A], EffectHandler[E, A]] with + def apply(h: ZIOEffectHandler[Env, E, A]): EffectHandler[E, A] = + LaminarZIOEffectHandler(h) + + def loggingHandler[E, A](name: String)( + underlying: EffectHandler[E, A] + ): EffectHandler[E, A] = + new EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + underlying( + effects.debugWithName(s"$name effects").debugLog(), + actions.debugWithName(s"$name actions").debugLog() + ) + +class LaminarZIOEffectHandler[Env, E, A](handler: ZIOEffectHandler[Env, E, A])( + using runtime: Runtime[Env] +) extends EffectHandler[E, A]: + + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + onMountCallback(ctx => + effects.foreach { effect => + Unsafe.unsafe { implicit unsafe => + runtime.unsafe + .runToFuture( + handler.handle(effect).either.runForeach { + case Right(a) => ZIO.succeed(actions.onNext(a)) + case Left(e) => ZIO.succeed(actions.onError(e)) + } + ) + } + }(ctx.owner) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala new file mode 100644 index 0000000..d2e0b4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag + +case class Bin[Source, +Value]( + label: String, + description: Option[String | HtmlElement], + color: ColorDef, + valueOf: Source => Value +): + def map[NewValue]( + f: Bin[Source, Value] => Source => NewValue + ): Bin[Source, NewValue] = + copy(valueOf = f(this)) + +case class Bins[T, U](bins: Seq[Bin[T, U]]): + def apply(v: T): Seq[U] = bins.map(_.valueOf(v)) + def map[A](r: Bin[T, U] => T => A): Bins[T, A] = + copy(bins = bins.map(_.map(r))) + + def renderSeparated( + r: Bin[T, U] => T => HtmlElement, + separator: => HtmlElement = span(" / "), + container: HtmlTag[org.scalajs.dom.html.Element] = span + )(v: T): HtmlElement = + container(interleave(map(r)(v), separator)) + + def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala new file mode 100644 index 0000000..a47f5ba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala @@ -0,0 +1,45 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait ButtonComponentsModule: + def buttons: ButtonComponents + + trait ButtonComponents: + def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + +trait DefaultButtonComponentsModule(using ctx: ComponentContext) + extends ButtonComponentsModule: + + override val buttons = new ButtonComponents: + + private inline def srHelp(id: String): Modifier[HtmlElement] = + ctx.messages + .opt(s"form.button.${id}.screenReaderHelp") + .map(sr => span(cls := "sr-only", sr)) + + override def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srHelp(id), + icon + ) + + override def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srHelp(id) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala new file mode 100644 index 0000000..15169f3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala @@ -0,0 +1,191 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom.html.UList +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind + +trait DashboardComponentsModule: + def dashboard: DashboardComponents + + // Only methods that create HtmlElements + trait DashboardComponents(using ComponentContext): + def section(label: String, children: Modifier[HtmlElement]): Div + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li + def cardInitials( + initials: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div + def count(label: String, value: Int, color: ColorDef): Span + def counts(children: HtmlElement*): Span + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + +trait DefaultDashboardComponentsModule(using ComponentContext) + extends DashboardComponentsModule: + override val dashboard: DashboardComponents = new DashboardComponents: + def section(label: String, children: Modifier[HtmlElement]): Div = + div( + cls("mt-3"), + h2( + cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), + label + ), + children + ) + + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = + ul( + cls( + "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + ), + children + ) + + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li = + li( + div( + cls("col-span-1 flex shadow-sm rounded-md"), + initials, + 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"), + div( + cls("text-gray-900 font-medium hover:text-gray-600 truncate"), + label + ), + p(cls("text-gray-500"), children) + ) + ) + ) + ) + + def cardInitials( + id: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div = + div( + cls( + "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" + ), + color.map(_(600).bg), + div(id), + children + ) + + def count(label: String, value: Int, color: ColorDef): Span = + span(color.text, title(label), s"${value}") + + def counts(children: HtmlElement*): Span = + span(interleave(children, span(" / "))) + + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div = + div( + cls("flex flex-col mt-2"), + div( + cls( + "align-middle min-w-full shadow sm:rounded-lg" + ), + L.table( + cls("min-w-full border-separate border-spacing-0"), + styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work + thead( + tr( + cls("border-t border-gray-200"), + headers.map(h => + th( + cls( + "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + h + ) + ) + ) + ), + tbody(cls("bg-white divide-y divide-gray-100"), sections) + ) + ) + ) + + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + td( + cls("bg-gray-50 border-gray-200 border-t border-b"), + cls( + "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + colSpan(cs), + label + ), + mods + ) + + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + mods, + td( + cls( + "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" + ), + label + ), + children.map(e => + td( + cls( + "whitespace-nowrap px-2 py-2 text-sm text-gray-500" + ), + e + ) + ) + ) + + private def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala new file mode 100644 index 0000000..4cff836 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala @@ -0,0 +1,56 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +import zio.* +import com.raquo.airstream.core.Observer +import scala.annotation.implicitNotFound + +trait EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] + +object EffectHandler: + given zioEffectHandler[Env, E, A](using + Runtime[Env] + ): Conversion[ZIOEffectHandler[Env, E, A], EffectHandler[E, A]] with + def apply(h: ZIOEffectHandler[Env, E, A]): EffectHandler[E, A] = + LaminarZIOEffectHandler(h) + + def loggingHandler[E, A](name: String)( + underlying: EffectHandler[E, A] + ): EffectHandler[E, A] = + new EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + underlying( + effects.debugWithName(s"$name effects").debugLog(), + actions.debugWithName(s"$name actions").debugLog() + ) + +class LaminarZIOEffectHandler[Env, E, A](handler: ZIOEffectHandler[Env, E, A])( + using runtime: Runtime[Env] +) extends EffectHandler[E, A]: + + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + onMountCallback(ctx => + effects.foreach { effect => + Unsafe.unsafe { implicit unsafe => + runtime.unsafe + .runToFuture( + handler.handle(effect).either.runForeach { + case Right(a) => ZIO.succeed(actions.onNext(a)) + case Left(e) => ZIO.succeed(actions.onError(e)) + } + ) + } + }(ctx.owner) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala new file mode 100644 index 0000000..df42132 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala @@ -0,0 +1,97 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.raquo.laminar.keys.ReactiveProp +import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent +import org.scalajs.dom.html +import com.raquo.laminar.modifiers.KeyUpdater + +trait FormComponentsModule extends LocalDateSelectModule: + def forms: FormComponents + + trait FormComponents: + def form(mods: Modifier[HtmlElement]*): HtmlElement + + def searchField(id: String, placeholderText: Option[String] = None)( + mods: Modifier[HtmlElement]* + ): HtmlElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement + +trait DefaultFormComponentsModule(using ctx: ComponentContext) + extends FormComponentsModule: + self: IconsModule => + override val forms = new FormComponents: + + override def form( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + override def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label( + forId := id, + cls := "sr-only", + "Hledat" + ), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + icons.`search-solid`() + ), + input( + tpe := "search", + name := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) + + override def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + name(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala new file mode 100644 index 0000000..d2e0b4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag + +case class Bin[Source, +Value]( + label: String, + description: Option[String | HtmlElement], + color: ColorDef, + valueOf: Source => Value +): + def map[NewValue]( + f: Bin[Source, Value] => Source => NewValue + ): Bin[Source, NewValue] = + copy(valueOf = f(this)) + +case class Bins[T, U](bins: Seq[Bin[T, U]]): + def apply(v: T): Seq[U] = bins.map(_.valueOf(v)) + def map[A](r: Bin[T, U] => T => A): Bins[T, A] = + copy(bins = bins.map(_.map(r))) + + def renderSeparated( + r: Bin[T, U] => T => HtmlElement, + separator: => HtmlElement = span(" / "), + container: HtmlTag[org.scalajs.dom.html.Element] = span + )(v: T): HtmlElement = + container(interleave(map(r)(v), separator)) + + def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala new file mode 100644 index 0000000..a47f5ba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala @@ -0,0 +1,45 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait ButtonComponentsModule: + def buttons: ButtonComponents + + trait ButtonComponents: + def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + +trait DefaultButtonComponentsModule(using ctx: ComponentContext) + extends ButtonComponentsModule: + + override val buttons = new ButtonComponents: + + private inline def srHelp(id: String): Modifier[HtmlElement] = + ctx.messages + .opt(s"form.button.${id}.screenReaderHelp") + .map(sr => span(cls := "sr-only", sr)) + + override def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srHelp(id), + icon + ) + + override def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srHelp(id) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala new file mode 100644 index 0000000..15169f3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala @@ -0,0 +1,191 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom.html.UList +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind + +trait DashboardComponentsModule: + def dashboard: DashboardComponents + + // Only methods that create HtmlElements + trait DashboardComponents(using ComponentContext): + def section(label: String, children: Modifier[HtmlElement]): Div + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li + def cardInitials( + initials: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div + def count(label: String, value: Int, color: ColorDef): Span + def counts(children: HtmlElement*): Span + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + +trait DefaultDashboardComponentsModule(using ComponentContext) + extends DashboardComponentsModule: + override val dashboard: DashboardComponents = new DashboardComponents: + def section(label: String, children: Modifier[HtmlElement]): Div = + div( + cls("mt-3"), + h2( + cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), + label + ), + children + ) + + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = + ul( + cls( + "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + ), + children + ) + + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li = + li( + div( + cls("col-span-1 flex shadow-sm rounded-md"), + initials, + 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"), + div( + cls("text-gray-900 font-medium hover:text-gray-600 truncate"), + label + ), + p(cls("text-gray-500"), children) + ) + ) + ) + ) + + def cardInitials( + id: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div = + div( + cls( + "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" + ), + color.map(_(600).bg), + div(id), + children + ) + + def count(label: String, value: Int, color: ColorDef): Span = + span(color.text, title(label), s"${value}") + + def counts(children: HtmlElement*): Span = + span(interleave(children, span(" / "))) + + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div = + div( + cls("flex flex-col mt-2"), + div( + cls( + "align-middle min-w-full shadow sm:rounded-lg" + ), + L.table( + cls("min-w-full border-separate border-spacing-0"), + styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work + thead( + tr( + cls("border-t border-gray-200"), + headers.map(h => + th( + cls( + "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + h + ) + ) + ) + ), + tbody(cls("bg-white divide-y divide-gray-100"), sections) + ) + ) + ) + + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + td( + cls("bg-gray-50 border-gray-200 border-t border-b"), + cls( + "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + colSpan(cs), + label + ), + mods + ) + + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + mods, + td( + cls( + "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" + ), + label + ), + children.map(e => + td( + cls( + "whitespace-nowrap px-2 py-2 text-sm text-gray-500" + ), + e + ) + ) + ) + + private def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala new file mode 100644 index 0000000..4cff836 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala @@ -0,0 +1,56 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +import zio.* +import com.raquo.airstream.core.Observer +import scala.annotation.implicitNotFound + +trait EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] + +object EffectHandler: + given zioEffectHandler[Env, E, A](using + Runtime[Env] + ): Conversion[ZIOEffectHandler[Env, E, A], EffectHandler[E, A]] with + def apply(h: ZIOEffectHandler[Env, E, A]): EffectHandler[E, A] = + LaminarZIOEffectHandler(h) + + def loggingHandler[E, A](name: String)( + underlying: EffectHandler[E, A] + ): EffectHandler[E, A] = + new EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + underlying( + effects.debugWithName(s"$name effects").debugLog(), + actions.debugWithName(s"$name actions").debugLog() + ) + +class LaminarZIOEffectHandler[Env, E, A](handler: ZIOEffectHandler[Env, E, A])( + using runtime: Runtime[Env] +) extends EffectHandler[E, A]: + + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + onMountCallback(ctx => + effects.foreach { effect => + Unsafe.unsafe { implicit unsafe => + runtime.unsafe + .runToFuture( + handler.handle(effect).either.runForeach { + case Right(a) => ZIO.succeed(actions.onNext(a)) + case Left(e) => ZIO.succeed(actions.onError(e)) + } + ) + } + }(ctx.owner) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala new file mode 100644 index 0000000..df42132 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala @@ -0,0 +1,97 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.raquo.laminar.keys.ReactiveProp +import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent +import org.scalajs.dom.html +import com.raquo.laminar.modifiers.KeyUpdater + +trait FormComponentsModule extends LocalDateSelectModule: + def forms: FormComponents + + trait FormComponents: + def form(mods: Modifier[HtmlElement]*): HtmlElement + + def searchField(id: String, placeholderText: Option[String] = None)( + mods: Modifier[HtmlElement]* + ): HtmlElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement + +trait DefaultFormComponentsModule(using ctx: ComponentContext) + extends FormComponentsModule: + self: IconsModule => + override val forms = new FormComponents: + + override def form( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + override def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label( + forId := id, + cls := "sr-only", + "Hledat" + ), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + icons.`search-solid`() + ), + input( + tpe := "search", + name := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) + + override def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + name(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala new file mode 100644 index 0000000..32fda8a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala @@ -0,0 +1,109 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.CustomAttrs + +trait IconsModule: + def icons: Icons + + trait Icons: + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement + def close(mods: Modifier[SvgElement]*): SvgElement + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement + def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement + +trait DefaultIconsModule(using ComponentContext) extends IconsModule: + override val icons: Icons = new Icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("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" + ) + ) + ) + + override def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala new file mode 100644 index 0000000..d2e0b4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag + +case class Bin[Source, +Value]( + label: String, + description: Option[String | HtmlElement], + color: ColorDef, + valueOf: Source => Value +): + def map[NewValue]( + f: Bin[Source, Value] => Source => NewValue + ): Bin[Source, NewValue] = + copy(valueOf = f(this)) + +case class Bins[T, U](bins: Seq[Bin[T, U]]): + def apply(v: T): Seq[U] = bins.map(_.valueOf(v)) + def map[A](r: Bin[T, U] => T => A): Bins[T, A] = + copy(bins = bins.map(_.map(r))) + + def renderSeparated( + r: Bin[T, U] => T => HtmlElement, + separator: => HtmlElement = span(" / "), + container: HtmlTag[org.scalajs.dom.html.Element] = span + )(v: T): HtmlElement = + container(interleave(map(r)(v), separator)) + + def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala new file mode 100644 index 0000000..a47f5ba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala @@ -0,0 +1,45 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait ButtonComponentsModule: + def buttons: ButtonComponents + + trait ButtonComponents: + def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + +trait DefaultButtonComponentsModule(using ctx: ComponentContext) + extends ButtonComponentsModule: + + override val buttons = new ButtonComponents: + + private inline def srHelp(id: String): Modifier[HtmlElement] = + ctx.messages + .opt(s"form.button.${id}.screenReaderHelp") + .map(sr => span(cls := "sr-only", sr)) + + override def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srHelp(id), + icon + ) + + override def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srHelp(id) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala new file mode 100644 index 0000000..15169f3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala @@ -0,0 +1,191 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom.html.UList +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind + +trait DashboardComponentsModule: + def dashboard: DashboardComponents + + // Only methods that create HtmlElements + trait DashboardComponents(using ComponentContext): + def section(label: String, children: Modifier[HtmlElement]): Div + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li + def cardInitials( + initials: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div + def count(label: String, value: Int, color: ColorDef): Span + def counts(children: HtmlElement*): Span + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + +trait DefaultDashboardComponentsModule(using ComponentContext) + extends DashboardComponentsModule: + override val dashboard: DashboardComponents = new DashboardComponents: + def section(label: String, children: Modifier[HtmlElement]): Div = + div( + cls("mt-3"), + h2( + cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), + label + ), + children + ) + + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = + ul( + cls( + "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + ), + children + ) + + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li = + li( + div( + cls("col-span-1 flex shadow-sm rounded-md"), + initials, + 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"), + div( + cls("text-gray-900 font-medium hover:text-gray-600 truncate"), + label + ), + p(cls("text-gray-500"), children) + ) + ) + ) + ) + + def cardInitials( + id: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div = + div( + cls( + "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" + ), + color.map(_(600).bg), + div(id), + children + ) + + def count(label: String, value: Int, color: ColorDef): Span = + span(color.text, title(label), s"${value}") + + def counts(children: HtmlElement*): Span = + span(interleave(children, span(" / "))) + + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div = + div( + cls("flex flex-col mt-2"), + div( + cls( + "align-middle min-w-full shadow sm:rounded-lg" + ), + L.table( + cls("min-w-full border-separate border-spacing-0"), + styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work + thead( + tr( + cls("border-t border-gray-200"), + headers.map(h => + th( + cls( + "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + h + ) + ) + ) + ), + tbody(cls("bg-white divide-y divide-gray-100"), sections) + ) + ) + ) + + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + td( + cls("bg-gray-50 border-gray-200 border-t border-b"), + cls( + "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + colSpan(cs), + label + ), + mods + ) + + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + mods, + td( + cls( + "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" + ), + label + ), + children.map(e => + td( + cls( + "whitespace-nowrap px-2 py-2 text-sm text-gray-500" + ), + e + ) + ) + ) + + private def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala new file mode 100644 index 0000000..4cff836 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala @@ -0,0 +1,56 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +import zio.* +import com.raquo.airstream.core.Observer +import scala.annotation.implicitNotFound + +trait EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] + +object EffectHandler: + given zioEffectHandler[Env, E, A](using + Runtime[Env] + ): Conversion[ZIOEffectHandler[Env, E, A], EffectHandler[E, A]] with + def apply(h: ZIOEffectHandler[Env, E, A]): EffectHandler[E, A] = + LaminarZIOEffectHandler(h) + + def loggingHandler[E, A](name: String)( + underlying: EffectHandler[E, A] + ): EffectHandler[E, A] = + new EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + underlying( + effects.debugWithName(s"$name effects").debugLog(), + actions.debugWithName(s"$name actions").debugLog() + ) + +class LaminarZIOEffectHandler[Env, E, A](handler: ZIOEffectHandler[Env, E, A])( + using runtime: Runtime[Env] +) extends EffectHandler[E, A]: + + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + onMountCallback(ctx => + effects.foreach { effect => + Unsafe.unsafe { implicit unsafe => + runtime.unsafe + .runToFuture( + handler.handle(effect).either.runForeach { + case Right(a) => ZIO.succeed(actions.onNext(a)) + case Left(e) => ZIO.succeed(actions.onError(e)) + } + ) + } + }(ctx.owner) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala new file mode 100644 index 0000000..df42132 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala @@ -0,0 +1,97 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.raquo.laminar.keys.ReactiveProp +import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent +import org.scalajs.dom.html +import com.raquo.laminar.modifiers.KeyUpdater + +trait FormComponentsModule extends LocalDateSelectModule: + def forms: FormComponents + + trait FormComponents: + def form(mods: Modifier[HtmlElement]*): HtmlElement + + def searchField(id: String, placeholderText: Option[String] = None)( + mods: Modifier[HtmlElement]* + ): HtmlElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement + +trait DefaultFormComponentsModule(using ctx: ComponentContext) + extends FormComponentsModule: + self: IconsModule => + override val forms = new FormComponents: + + override def form( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + override def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label( + forId := id, + cls := "sr-only", + "Hledat" + ), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + icons.`search-solid`() + ), + input( + tpe := "search", + name := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) + + override def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + name(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala new file mode 100644 index 0000000..32fda8a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala @@ -0,0 +1,109 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.CustomAttrs + +trait IconsModule: + def icons: Icons + + trait Icons: + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement + def close(mods: Modifier[SvgElement]*): SvgElement + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement + def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement + +trait DefaultIconsModule(using ComponentContext) extends IconsModule: + override val icons: Icons = new Icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("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" + ) + ) + ) + + override def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala new file mode 100644 index 0000000..f7bacb1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -0,0 +1,35 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +abstract class LaminarComponent[M, A, E]( + effectHandler: EffectHandler[E, A] +) extends Module[M, A, E]: + def render(m: Signal[M], actions: Observer[A]): HtmlElement + + val element: HtmlElement = + val actions = new EventBus[A] + + val zero @ (_, effect) = init + + val initialEffect$ = (effect match + case Some(e) => EventStream.fromValue(e) + case _ => EventStream.empty + ) + + val actions$ = actions.events.recover(handleFailure) + + val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + handle(a, m) + } + + val nextEffects$ = processor$.changes.collect { case (_, Some(e)) => e } + + val model$ = processor$.map(_._1) + + val effect$ = EventStream.merge(initialEffect$, nextEffects$) + + render(model$, actions.writer).amend( + effectHandler(effect$, actions.writer) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala new file mode 100644 index 0000000..d2e0b4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag + +case class Bin[Source, +Value]( + label: String, + description: Option[String | HtmlElement], + color: ColorDef, + valueOf: Source => Value +): + def map[NewValue]( + f: Bin[Source, Value] => Source => NewValue + ): Bin[Source, NewValue] = + copy(valueOf = f(this)) + +case class Bins[T, U](bins: Seq[Bin[T, U]]): + def apply(v: T): Seq[U] = bins.map(_.valueOf(v)) + def map[A](r: Bin[T, U] => T => A): Bins[T, A] = + copy(bins = bins.map(_.map(r))) + + def renderSeparated( + r: Bin[T, U] => T => HtmlElement, + separator: => HtmlElement = span(" / "), + container: HtmlTag[org.scalajs.dom.html.Element] = span + )(v: T): HtmlElement = + container(interleave(map(r)(v), separator)) + + def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala new file mode 100644 index 0000000..a47f5ba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala @@ -0,0 +1,45 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait ButtonComponentsModule: + def buttons: ButtonComponents + + trait ButtonComponents: + def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + +trait DefaultButtonComponentsModule(using ctx: ComponentContext) + extends ButtonComponentsModule: + + override val buttons = new ButtonComponents: + + private inline def srHelp(id: String): Modifier[HtmlElement] = + ctx.messages + .opt(s"form.button.${id}.screenReaderHelp") + .map(sr => span(cls := "sr-only", sr)) + + override def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srHelp(id), + icon + ) + + override def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srHelp(id) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala new file mode 100644 index 0000000..15169f3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala @@ -0,0 +1,191 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom.html.UList +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind + +trait DashboardComponentsModule: + def dashboard: DashboardComponents + + // Only methods that create HtmlElements + trait DashboardComponents(using ComponentContext): + def section(label: String, children: Modifier[HtmlElement]): Div + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li + def cardInitials( + initials: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div + def count(label: String, value: Int, color: ColorDef): Span + def counts(children: HtmlElement*): Span + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + +trait DefaultDashboardComponentsModule(using ComponentContext) + extends DashboardComponentsModule: + override val dashboard: DashboardComponents = new DashboardComponents: + def section(label: String, children: Modifier[HtmlElement]): Div = + div( + cls("mt-3"), + h2( + cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), + label + ), + children + ) + + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = + ul( + cls( + "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + ), + children + ) + + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li = + li( + div( + cls("col-span-1 flex shadow-sm rounded-md"), + initials, + 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"), + div( + cls("text-gray-900 font-medium hover:text-gray-600 truncate"), + label + ), + p(cls("text-gray-500"), children) + ) + ) + ) + ) + + def cardInitials( + id: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div = + div( + cls( + "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" + ), + color.map(_(600).bg), + div(id), + children + ) + + def count(label: String, value: Int, color: ColorDef): Span = + span(color.text, title(label), s"${value}") + + def counts(children: HtmlElement*): Span = + span(interleave(children, span(" / "))) + + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div = + div( + cls("flex flex-col mt-2"), + div( + cls( + "align-middle min-w-full shadow sm:rounded-lg" + ), + L.table( + cls("min-w-full border-separate border-spacing-0"), + styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work + thead( + tr( + cls("border-t border-gray-200"), + headers.map(h => + th( + cls( + "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + h + ) + ) + ) + ), + tbody(cls("bg-white divide-y divide-gray-100"), sections) + ) + ) + ) + + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + td( + cls("bg-gray-50 border-gray-200 border-t border-b"), + cls( + "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + colSpan(cs), + label + ), + mods + ) + + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + mods, + td( + cls( + "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" + ), + label + ), + children.map(e => + td( + cls( + "whitespace-nowrap px-2 py-2 text-sm text-gray-500" + ), + e + ) + ) + ) + + private def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala new file mode 100644 index 0000000..4cff836 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala @@ -0,0 +1,56 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +import zio.* +import com.raquo.airstream.core.Observer +import scala.annotation.implicitNotFound + +trait EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] + +object EffectHandler: + given zioEffectHandler[Env, E, A](using + Runtime[Env] + ): Conversion[ZIOEffectHandler[Env, E, A], EffectHandler[E, A]] with + def apply(h: ZIOEffectHandler[Env, E, A]): EffectHandler[E, A] = + LaminarZIOEffectHandler(h) + + def loggingHandler[E, A](name: String)( + underlying: EffectHandler[E, A] + ): EffectHandler[E, A] = + new EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + underlying( + effects.debugWithName(s"$name effects").debugLog(), + actions.debugWithName(s"$name actions").debugLog() + ) + +class LaminarZIOEffectHandler[Env, E, A](handler: ZIOEffectHandler[Env, E, A])( + using runtime: Runtime[Env] +) extends EffectHandler[E, A]: + + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + onMountCallback(ctx => + effects.foreach { effect => + Unsafe.unsafe { implicit unsafe => + runtime.unsafe + .runToFuture( + handler.handle(effect).either.runForeach { + case Right(a) => ZIO.succeed(actions.onNext(a)) + case Left(e) => ZIO.succeed(actions.onError(e)) + } + ) + } + }(ctx.owner) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala new file mode 100644 index 0000000..df42132 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala @@ -0,0 +1,97 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.raquo.laminar.keys.ReactiveProp +import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent +import org.scalajs.dom.html +import com.raquo.laminar.modifiers.KeyUpdater + +trait FormComponentsModule extends LocalDateSelectModule: + def forms: FormComponents + + trait FormComponents: + def form(mods: Modifier[HtmlElement]*): HtmlElement + + def searchField(id: String, placeholderText: Option[String] = None)( + mods: Modifier[HtmlElement]* + ): HtmlElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement + +trait DefaultFormComponentsModule(using ctx: ComponentContext) + extends FormComponentsModule: + self: IconsModule => + override val forms = new FormComponents: + + override def form( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + override def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label( + forId := id, + cls := "sr-only", + "Hledat" + ), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + icons.`search-solid`() + ), + input( + tpe := "search", + name := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) + + override def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + name(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala new file mode 100644 index 0000000..32fda8a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala @@ -0,0 +1,109 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.CustomAttrs + +trait IconsModule: + def icons: Icons + + trait Icons: + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement + def close(mods: Modifier[SvgElement]*): SvgElement + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement + def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement + +trait DefaultIconsModule(using ComponentContext) extends IconsModule: + override val icons: Icons = new Icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("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" + ) + ) + ) + + override def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala new file mode 100644 index 0000000..f7bacb1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -0,0 +1,35 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +abstract class LaminarComponent[M, A, E]( + effectHandler: EffectHandler[E, A] +) extends Module[M, A, E]: + def render(m: Signal[M], actions: Observer[A]): HtmlElement + + val element: HtmlElement = + val actions = new EventBus[A] + + val zero @ (_, effect) = init + + val initialEffect$ = (effect match + case Some(e) => EventStream.fromValue(e) + case _ => EventStream.empty + ) + + val actions$ = actions.events.recover(handleFailure) + + val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + handle(a, m) + } + + val nextEffects$ = processor$.changes.collect { case (_, Some(e)) => e } + + val model$ = processor$.map(_._1) + + val effect$ = EventStream.merge(initialEffect$, nextEffects$) + + render(model$, actions.writer).amend( + effectHandler(effect$, actions.writer) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala new file mode 100644 index 0000000..98e31b9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala @@ -0,0 +1,114 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html.Paragraph +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.nodes.TextNode + +trait ListComponentsModule: + + def list: ListComponents + + trait ListComponents(using ComponentContext): + def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] + def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None + ): Li + def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] + def listSection(header: String, list: HtmlElement): Div + def navigation(sections: Modifier[HtmlElement]): HtmlElement + +trait DefaultListComponentsModule(using ComponentContext) + extends ListComponentsModule: + override val list: ListComponents = new ListComponents: + override def label( + text: String, + color: ColorKind + ): ReactiveHtmlElement[Paragraph] = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + text + ) + + override def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None + ): Li = + li( + cls("group"), + div( + cls( + "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" + ), + avatar.map(a => + div( + cls("flex-shrink-0"), + div( + cls( + "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" + ), + a + ) + ) + ), + div( + cls("flex-1 min-w-0"), + p( + cls("text-sm font-medium text-gray-900"), + title, + span(cls("float-right"), right) + ), + subtitle.map(st => + p( + cls("text-sm text-gray-500 truncate"), + st + ) + ) + ) + ) + ) + + override def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = + ul( + cls("relative z-0 divide-y divide-gray-200"), + role("list"), + children + ) + + override def listSection( + header: String, + list: HtmlElement + ): Div = + div( + cls("relative"), + div( + 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 + ), + list + ) + + override def navigation(sections: Modifier[HtmlElement]): HtmlElement = + nav( + cls("flex-1 min-h-0 overflow-y-auto"), + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala new file mode 100644 index 0000000..d2e0b4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag + +case class Bin[Source, +Value]( + label: String, + description: Option[String | HtmlElement], + color: ColorDef, + valueOf: Source => Value +): + def map[NewValue]( + f: Bin[Source, Value] => Source => NewValue + ): Bin[Source, NewValue] = + copy(valueOf = f(this)) + +case class Bins[T, U](bins: Seq[Bin[T, U]]): + def apply(v: T): Seq[U] = bins.map(_.valueOf(v)) + def map[A](r: Bin[T, U] => T => A): Bins[T, A] = + copy(bins = bins.map(_.map(r))) + + def renderSeparated( + r: Bin[T, U] => T => HtmlElement, + separator: => HtmlElement = span(" / "), + container: HtmlTag[org.scalajs.dom.html.Element] = span + )(v: T): HtmlElement = + container(interleave(map(r)(v), separator)) + + def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala new file mode 100644 index 0000000..a47f5ba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala @@ -0,0 +1,45 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait ButtonComponentsModule: + def buttons: ButtonComponents + + trait ButtonComponents: + def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + +trait DefaultButtonComponentsModule(using ctx: ComponentContext) + extends ButtonComponentsModule: + + override val buttons = new ButtonComponents: + + private inline def srHelp(id: String): Modifier[HtmlElement] = + ctx.messages + .opt(s"form.button.${id}.screenReaderHelp") + .map(sr => span(cls := "sr-only", sr)) + + override def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srHelp(id), + icon + ) + + override def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srHelp(id) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala new file mode 100644 index 0000000..15169f3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala @@ -0,0 +1,191 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom.html.UList +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind + +trait DashboardComponentsModule: + def dashboard: DashboardComponents + + // Only methods that create HtmlElements + trait DashboardComponents(using ComponentContext): + def section(label: String, children: Modifier[HtmlElement]): Div + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li + def cardInitials( + initials: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div + def count(label: String, value: Int, color: ColorDef): Span + def counts(children: HtmlElement*): Span + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + +trait DefaultDashboardComponentsModule(using ComponentContext) + extends DashboardComponentsModule: + override val dashboard: DashboardComponents = new DashboardComponents: + def section(label: String, children: Modifier[HtmlElement]): Div = + div( + cls("mt-3"), + h2( + cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), + label + ), + children + ) + + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = + ul( + cls( + "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + ), + children + ) + + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li = + li( + div( + cls("col-span-1 flex shadow-sm rounded-md"), + initials, + 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"), + div( + cls("text-gray-900 font-medium hover:text-gray-600 truncate"), + label + ), + p(cls("text-gray-500"), children) + ) + ) + ) + ) + + def cardInitials( + id: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div = + div( + cls( + "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" + ), + color.map(_(600).bg), + div(id), + children + ) + + def count(label: String, value: Int, color: ColorDef): Span = + span(color.text, title(label), s"${value}") + + def counts(children: HtmlElement*): Span = + span(interleave(children, span(" / "))) + + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div = + div( + cls("flex flex-col mt-2"), + div( + cls( + "align-middle min-w-full shadow sm:rounded-lg" + ), + L.table( + cls("min-w-full border-separate border-spacing-0"), + styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work + thead( + tr( + cls("border-t border-gray-200"), + headers.map(h => + th( + cls( + "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + h + ) + ) + ) + ), + tbody(cls("bg-white divide-y divide-gray-100"), sections) + ) + ) + ) + + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + td( + cls("bg-gray-50 border-gray-200 border-t border-b"), + cls( + "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + colSpan(cs), + label + ), + mods + ) + + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + mods, + td( + cls( + "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" + ), + label + ), + children.map(e => + td( + cls( + "whitespace-nowrap px-2 py-2 text-sm text-gray-500" + ), + e + ) + ) + ) + + private def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala new file mode 100644 index 0000000..4cff836 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala @@ -0,0 +1,56 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +import zio.* +import com.raquo.airstream.core.Observer +import scala.annotation.implicitNotFound + +trait EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] + +object EffectHandler: + given zioEffectHandler[Env, E, A](using + Runtime[Env] + ): Conversion[ZIOEffectHandler[Env, E, A], EffectHandler[E, A]] with + def apply(h: ZIOEffectHandler[Env, E, A]): EffectHandler[E, A] = + LaminarZIOEffectHandler(h) + + def loggingHandler[E, A](name: String)( + underlying: EffectHandler[E, A] + ): EffectHandler[E, A] = + new EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + underlying( + effects.debugWithName(s"$name effects").debugLog(), + actions.debugWithName(s"$name actions").debugLog() + ) + +class LaminarZIOEffectHandler[Env, E, A](handler: ZIOEffectHandler[Env, E, A])( + using runtime: Runtime[Env] +) extends EffectHandler[E, A]: + + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + onMountCallback(ctx => + effects.foreach { effect => + Unsafe.unsafe { implicit unsafe => + runtime.unsafe + .runToFuture( + handler.handle(effect).either.runForeach { + case Right(a) => ZIO.succeed(actions.onNext(a)) + case Left(e) => ZIO.succeed(actions.onError(e)) + } + ) + } + }(ctx.owner) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala new file mode 100644 index 0000000..df42132 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala @@ -0,0 +1,97 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.raquo.laminar.keys.ReactiveProp +import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent +import org.scalajs.dom.html +import com.raquo.laminar.modifiers.KeyUpdater + +trait FormComponentsModule extends LocalDateSelectModule: + def forms: FormComponents + + trait FormComponents: + def form(mods: Modifier[HtmlElement]*): HtmlElement + + def searchField(id: String, placeholderText: Option[String] = None)( + mods: Modifier[HtmlElement]* + ): HtmlElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement + +trait DefaultFormComponentsModule(using ctx: ComponentContext) + extends FormComponentsModule: + self: IconsModule => + override val forms = new FormComponents: + + override def form( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + override def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label( + forId := id, + cls := "sr-only", + "Hledat" + ), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + icons.`search-solid`() + ), + input( + tpe := "search", + name := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) + + override def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + name(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala new file mode 100644 index 0000000..32fda8a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala @@ -0,0 +1,109 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.CustomAttrs + +trait IconsModule: + def icons: Icons + + trait Icons: + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement + def close(mods: Modifier[SvgElement]*): SvgElement + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement + def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement + +trait DefaultIconsModule(using ComponentContext) extends IconsModule: + override val icons: Icons = new Icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("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" + ) + ) + ) + + override def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala new file mode 100644 index 0000000..f7bacb1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -0,0 +1,35 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +abstract class LaminarComponent[M, A, E]( + effectHandler: EffectHandler[E, A] +) extends Module[M, A, E]: + def render(m: Signal[M], actions: Observer[A]): HtmlElement + + val element: HtmlElement = + val actions = new EventBus[A] + + val zero @ (_, effect) = init + + val initialEffect$ = (effect match + case Some(e) => EventStream.fromValue(e) + case _ => EventStream.empty + ) + + val actions$ = actions.events.recover(handleFailure) + + val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + handle(a, m) + } + + val nextEffects$ = processor$.changes.collect { case (_, Some(e)) => e } + + val model$ = processor$.map(_._1) + + val effect$ = EventStream.merge(initialEffect$, nextEffects$) + + render(model$, actions.writer).amend( + effectHandler(effect$, actions.writer) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala new file mode 100644 index 0000000..98e31b9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala @@ -0,0 +1,114 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html.Paragraph +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.nodes.TextNode + +trait ListComponentsModule: + + def list: ListComponents + + trait ListComponents(using ComponentContext): + def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] + def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None + ): Li + def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] + def listSection(header: String, list: HtmlElement): Div + def navigation(sections: Modifier[HtmlElement]): HtmlElement + +trait DefaultListComponentsModule(using ComponentContext) + extends ListComponentsModule: + override val list: ListComponents = new ListComponents: + override def label( + text: String, + color: ColorKind + ): ReactiveHtmlElement[Paragraph] = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + text + ) + + override def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None + ): Li = + li( + cls("group"), + div( + cls( + "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" + ), + avatar.map(a => + div( + cls("flex-shrink-0"), + div( + cls( + "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" + ), + a + ) + ) + ), + div( + cls("flex-1 min-w-0"), + p( + cls("text-sm font-medium text-gray-900"), + title, + span(cls("float-right"), right) + ), + subtitle.map(st => + p( + cls("text-sm text-gray-500 truncate"), + st + ) + ) + ) + ) + ) + + override def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = + ul( + cls("relative z-0 divide-y divide-gray-200"), + role("list"), + children + ) + + override def listSection( + header: String, + list: HtmlElement + ): Div = + div( + cls("relative"), + div( + 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 + ), + list + ) + + override def navigation(sections: Modifier[HtmlElement]): HtmlElement = + nav( + cls("flex-1 min-h-0 overflow-y-auto"), + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala new file mode 100644 index 0000000..31d860d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -0,0 +1,72 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.raquo.laminar.keys.ReactiveProp +import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent +import org.scalajs.dom.html +import com.raquo.laminar.modifiers.KeyUpdater + +trait LocalDateSelectModule: + val localDateSelect: LocalDateSelect = new LocalDateSelect + + class LocalDateSelect: + import LocalDateSelect.* + + def valueUpdater( + signal: Signal[Option[LocalDate]] + ): KeyUpdater.PropUpdater[String, String] = + L.value <-- signal.map(_.map(formatDate).getOrElse("")) + + // Does not work in `controlled` + // Laminar refuses the custom prop, requries its own `value` or `checked` + val value: ReactiveProp[Option[LocalDate], String] = + customProp("value", OptLocalDateAsStringCodec) + + val min: ReactiveProp[LocalDate, String] = + customProp("min", LocalDateAsStringCodec) + + val max: ReactiveProp[LocalDate, String] = + customProp("max", LocalDateAsStringCodec) + + val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => + d + } + + val onOptInput + : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + onInput.mapToValue.setAsValue.map(parseDate) + + object LocalDateSelect: + import java.time.format.DateTimeFormatter + import java.time.LocalDate + import com.raquo.domtypes.generic.codecs.Codec + + private val formatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd") + + private def parseDate(date: String): Option[LocalDate] = + import scala.util.Try + if date.isEmpty then None + else Try(LocalDate.parse(date, formatter)).toOption + + private def formatDate(date: LocalDate): String = + formatter.format(date) + + object LocalDateAsStringCodec extends Codec[LocalDate, String]: + override def decode(domValue: String): LocalDate = + parseDate(domValue).orNull + + override def encode(scalaValue: LocalDate): String = + formatDate(scalaValue) + + object OptLocalDateAsStringCodec extends Codec[Option[LocalDate], String]: + override def decode(domValue: String): Option[LocalDate] = + parseDate(domValue) + + override def encode(scalaValue: Option[LocalDate]): String = + scalaValue.map(formatDate).getOrElse("") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala new file mode 100644 index 0000000..d2e0b4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag + +case class Bin[Source, +Value]( + label: String, + description: Option[String | HtmlElement], + color: ColorDef, + valueOf: Source => Value +): + def map[NewValue]( + f: Bin[Source, Value] => Source => NewValue + ): Bin[Source, NewValue] = + copy(valueOf = f(this)) + +case class Bins[T, U](bins: Seq[Bin[T, U]]): + def apply(v: T): Seq[U] = bins.map(_.valueOf(v)) + def map[A](r: Bin[T, U] => T => A): Bins[T, A] = + copy(bins = bins.map(_.map(r))) + + def renderSeparated( + r: Bin[T, U] => T => HtmlElement, + separator: => HtmlElement = span(" / "), + container: HtmlTag[org.scalajs.dom.html.Element] = span + )(v: T): HtmlElement = + container(interleave(map(r)(v), separator)) + + def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala new file mode 100644 index 0000000..a47f5ba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala @@ -0,0 +1,45 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait ButtonComponentsModule: + def buttons: ButtonComponents + + trait ButtonComponents: + def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + +trait DefaultButtonComponentsModule(using ctx: ComponentContext) + extends ButtonComponentsModule: + + override val buttons = new ButtonComponents: + + private inline def srHelp(id: String): Modifier[HtmlElement] = + ctx.messages + .opt(s"form.button.${id}.screenReaderHelp") + .map(sr => span(cls := "sr-only", sr)) + + override def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srHelp(id), + icon + ) + + override def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srHelp(id) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala new file mode 100644 index 0000000..15169f3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala @@ -0,0 +1,191 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom.html.UList +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind + +trait DashboardComponentsModule: + def dashboard: DashboardComponents + + // Only methods that create HtmlElements + trait DashboardComponents(using ComponentContext): + def section(label: String, children: Modifier[HtmlElement]): Div + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li + def cardInitials( + initials: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div + def count(label: String, value: Int, color: ColorDef): Span + def counts(children: HtmlElement*): Span + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + +trait DefaultDashboardComponentsModule(using ComponentContext) + extends DashboardComponentsModule: + override val dashboard: DashboardComponents = new DashboardComponents: + def section(label: String, children: Modifier[HtmlElement]): Div = + div( + cls("mt-3"), + h2( + cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), + label + ), + children + ) + + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = + ul( + cls( + "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + ), + children + ) + + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li = + li( + div( + cls("col-span-1 flex shadow-sm rounded-md"), + initials, + 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"), + div( + cls("text-gray-900 font-medium hover:text-gray-600 truncate"), + label + ), + p(cls("text-gray-500"), children) + ) + ) + ) + ) + + def cardInitials( + id: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div = + div( + cls( + "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" + ), + color.map(_(600).bg), + div(id), + children + ) + + def count(label: String, value: Int, color: ColorDef): Span = + span(color.text, title(label), s"${value}") + + def counts(children: HtmlElement*): Span = + span(interleave(children, span(" / "))) + + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div = + div( + cls("flex flex-col mt-2"), + div( + cls( + "align-middle min-w-full shadow sm:rounded-lg" + ), + L.table( + cls("min-w-full border-separate border-spacing-0"), + styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work + thead( + tr( + cls("border-t border-gray-200"), + headers.map(h => + th( + cls( + "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + h + ) + ) + ) + ), + tbody(cls("bg-white divide-y divide-gray-100"), sections) + ) + ) + ) + + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + td( + cls("bg-gray-50 border-gray-200 border-t border-b"), + cls( + "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + colSpan(cs), + label + ), + mods + ) + + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + mods, + td( + cls( + "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" + ), + label + ), + children.map(e => + td( + cls( + "whitespace-nowrap px-2 py-2 text-sm text-gray-500" + ), + e + ) + ) + ) + + private def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala new file mode 100644 index 0000000..4cff836 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala @@ -0,0 +1,56 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +import zio.* +import com.raquo.airstream.core.Observer +import scala.annotation.implicitNotFound + +trait EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] + +object EffectHandler: + given zioEffectHandler[Env, E, A](using + Runtime[Env] + ): Conversion[ZIOEffectHandler[Env, E, A], EffectHandler[E, A]] with + def apply(h: ZIOEffectHandler[Env, E, A]): EffectHandler[E, A] = + LaminarZIOEffectHandler(h) + + def loggingHandler[E, A](name: String)( + underlying: EffectHandler[E, A] + ): EffectHandler[E, A] = + new EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + underlying( + effects.debugWithName(s"$name effects").debugLog(), + actions.debugWithName(s"$name actions").debugLog() + ) + +class LaminarZIOEffectHandler[Env, E, A](handler: ZIOEffectHandler[Env, E, A])( + using runtime: Runtime[Env] +) extends EffectHandler[E, A]: + + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + onMountCallback(ctx => + effects.foreach { effect => + Unsafe.unsafe { implicit unsafe => + runtime.unsafe + .runToFuture( + handler.handle(effect).either.runForeach { + case Right(a) => ZIO.succeed(actions.onNext(a)) + case Left(e) => ZIO.succeed(actions.onError(e)) + } + ) + } + }(ctx.owner) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala new file mode 100644 index 0000000..df42132 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala @@ -0,0 +1,97 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.raquo.laminar.keys.ReactiveProp +import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent +import org.scalajs.dom.html +import com.raquo.laminar.modifiers.KeyUpdater + +trait FormComponentsModule extends LocalDateSelectModule: + def forms: FormComponents + + trait FormComponents: + def form(mods: Modifier[HtmlElement]*): HtmlElement + + def searchField(id: String, placeholderText: Option[String] = None)( + mods: Modifier[HtmlElement]* + ): HtmlElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement + +trait DefaultFormComponentsModule(using ctx: ComponentContext) + extends FormComponentsModule: + self: IconsModule => + override val forms = new FormComponents: + + override def form( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + override def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label( + forId := id, + cls := "sr-only", + "Hledat" + ), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + icons.`search-solid`() + ), + input( + tpe := "search", + name := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) + + override def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + name(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala new file mode 100644 index 0000000..32fda8a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala @@ -0,0 +1,109 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.CustomAttrs + +trait IconsModule: + def icons: Icons + + trait Icons: + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement + def close(mods: Modifier[SvgElement]*): SvgElement + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement + def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement + +trait DefaultIconsModule(using ComponentContext) extends IconsModule: + override val icons: Icons = new Icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("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" + ) + ) + ) + + override def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala new file mode 100644 index 0000000..f7bacb1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -0,0 +1,35 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +abstract class LaminarComponent[M, A, E]( + effectHandler: EffectHandler[E, A] +) extends Module[M, A, E]: + def render(m: Signal[M], actions: Observer[A]): HtmlElement + + val element: HtmlElement = + val actions = new EventBus[A] + + val zero @ (_, effect) = init + + val initialEffect$ = (effect match + case Some(e) => EventStream.fromValue(e) + case _ => EventStream.empty + ) + + val actions$ = actions.events.recover(handleFailure) + + val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + handle(a, m) + } + + val nextEffects$ = processor$.changes.collect { case (_, Some(e)) => e } + + val model$ = processor$.map(_._1) + + val effect$ = EventStream.merge(initialEffect$, nextEffects$) + + render(model$, actions.writer).amend( + effectHandler(effect$, actions.writer) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala new file mode 100644 index 0000000..98e31b9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala @@ -0,0 +1,114 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html.Paragraph +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.nodes.TextNode + +trait ListComponentsModule: + + def list: ListComponents + + trait ListComponents(using ComponentContext): + def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] + def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None + ): Li + def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] + def listSection(header: String, list: HtmlElement): Div + def navigation(sections: Modifier[HtmlElement]): HtmlElement + +trait DefaultListComponentsModule(using ComponentContext) + extends ListComponentsModule: + override val list: ListComponents = new ListComponents: + override def label( + text: String, + color: ColorKind + ): ReactiveHtmlElement[Paragraph] = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + text + ) + + override def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None + ): Li = + li( + cls("group"), + div( + cls( + "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" + ), + avatar.map(a => + div( + cls("flex-shrink-0"), + div( + cls( + "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" + ), + a + ) + ) + ), + div( + cls("flex-1 min-w-0"), + p( + cls("text-sm font-medium text-gray-900"), + title, + span(cls("float-right"), right) + ), + subtitle.map(st => + p( + cls("text-sm text-gray-500 truncate"), + st + ) + ) + ) + ) + ) + + override def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = + ul( + cls("relative z-0 divide-y divide-gray-200"), + role("list"), + children + ) + + override def listSection( + header: String, + list: HtmlElement + ): Div = + div( + cls("relative"), + div( + 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 + ), + list + ) + + override def navigation(sections: Modifier[HtmlElement]): HtmlElement = + nav( + cls("flex-1 min-h-0 overflow-y-auto"), + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala new file mode 100644 index 0000000..31d860d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -0,0 +1,72 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.raquo.laminar.keys.ReactiveProp +import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent +import org.scalajs.dom.html +import com.raquo.laminar.modifiers.KeyUpdater + +trait LocalDateSelectModule: + val localDateSelect: LocalDateSelect = new LocalDateSelect + + class LocalDateSelect: + import LocalDateSelect.* + + def valueUpdater( + signal: Signal[Option[LocalDate]] + ): KeyUpdater.PropUpdater[String, String] = + L.value <-- signal.map(_.map(formatDate).getOrElse("")) + + // Does not work in `controlled` + // Laminar refuses the custom prop, requries its own `value` or `checked` + val value: ReactiveProp[Option[LocalDate], String] = + customProp("value", OptLocalDateAsStringCodec) + + val min: ReactiveProp[LocalDate, String] = + customProp("min", LocalDateAsStringCodec) + + val max: ReactiveProp[LocalDate, String] = + customProp("max", LocalDateAsStringCodec) + + val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => + d + } + + val onOptInput + : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + onInput.mapToValue.setAsValue.map(parseDate) + + object LocalDateSelect: + import java.time.format.DateTimeFormatter + import java.time.LocalDate + import com.raquo.domtypes.generic.codecs.Codec + + private val formatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd") + + private def parseDate(date: String): Option[LocalDate] = + import scala.util.Try + if date.isEmpty then None + else Try(LocalDate.parse(date, formatter)).toOption + + private def formatDate(date: LocalDate): String = + formatter.format(date) + + object LocalDateAsStringCodec extends Codec[LocalDate, String]: + override def decode(domValue: String): LocalDate = + parseDate(domValue).orNull + + override def encode(scalaValue: LocalDate): String = + formatDate(scalaValue) + + object OptLocalDateAsStringCodec extends Codec[Option[LocalDate], String]: + override def decode(domValue: String): Option[LocalDate] = + parseDate(domValue) + + override def encode(scalaValue: Option[LocalDate]): String = + scalaValue.map(formatDate).getOrElse("") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala new file mode 100644 index 0000000..3f3e7d0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala @@ -0,0 +1,71 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait PageComponentsModule: + + val page: PageComponents + + trait PageComponents: + def container( + children: Modifier[HtmlElement]* + ): HtmlElement + + def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement + + def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement] = emptyMod, + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement + + /** Visage for clickable text, like links + */ + def clickable: Modifier[HtmlElement] + +trait DefaultPageComponentsModule(using ComponentContext) + extends PageComponentsModule: + + override val page: PageComponents = new PageComponents: + override def container( + children: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), + children + ) + + override def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement = + div( + cls("p-8 bg-gray-100 h-full"), + header, + children + ) + + override def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + div( + cls("pb-5 border-b border-gray-200"), + div(cls("float-right"), right), + h1( + cls("text-2xl leading-6 font-medium text-gray-900"), + title + ), + subtitle.map( + p( + cls("text-sm font-medium text-gray-500"), + _ + ) + ) + ) + + override def clickable: Modifier[HtmlElement] = + cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala new file mode 100644 index 0000000..d2e0b4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag + +case class Bin[Source, +Value]( + label: String, + description: Option[String | HtmlElement], + color: ColorDef, + valueOf: Source => Value +): + def map[NewValue]( + f: Bin[Source, Value] => Source => NewValue + ): Bin[Source, NewValue] = + copy(valueOf = f(this)) + +case class Bins[T, U](bins: Seq[Bin[T, U]]): + def apply(v: T): Seq[U] = bins.map(_.valueOf(v)) + def map[A](r: Bin[T, U] => T => A): Bins[T, A] = + copy(bins = bins.map(_.map(r))) + + def renderSeparated( + r: Bin[T, U] => T => HtmlElement, + separator: => HtmlElement = span(" / "), + container: HtmlTag[org.scalajs.dom.html.Element] = span + )(v: T): HtmlElement = + container(interleave(map(r)(v), separator)) + + def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala new file mode 100644 index 0000000..a47f5ba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala @@ -0,0 +1,45 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait ButtonComponentsModule: + def buttons: ButtonComponents + + trait ButtonComponents: + def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + +trait DefaultButtonComponentsModule(using ctx: ComponentContext) + extends ButtonComponentsModule: + + override val buttons = new ButtonComponents: + + private inline def srHelp(id: String): Modifier[HtmlElement] = + ctx.messages + .opt(s"form.button.${id}.screenReaderHelp") + .map(sr => span(cls := "sr-only", sr)) + + override def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srHelp(id), + icon + ) + + override def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srHelp(id) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala new file mode 100644 index 0000000..15169f3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala @@ -0,0 +1,191 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom.html.UList +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind + +trait DashboardComponentsModule: + def dashboard: DashboardComponents + + // Only methods that create HtmlElements + trait DashboardComponents(using ComponentContext): + def section(label: String, children: Modifier[HtmlElement]): Div + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li + def cardInitials( + initials: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div + def count(label: String, value: Int, color: ColorDef): Span + def counts(children: HtmlElement*): Span + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + +trait DefaultDashboardComponentsModule(using ComponentContext) + extends DashboardComponentsModule: + override val dashboard: DashboardComponents = new DashboardComponents: + def section(label: String, children: Modifier[HtmlElement]): Div = + div( + cls("mt-3"), + h2( + cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), + label + ), + children + ) + + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = + ul( + cls( + "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + ), + children + ) + + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li = + li( + div( + cls("col-span-1 flex shadow-sm rounded-md"), + initials, + 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"), + div( + cls("text-gray-900 font-medium hover:text-gray-600 truncate"), + label + ), + p(cls("text-gray-500"), children) + ) + ) + ) + ) + + def cardInitials( + id: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div = + div( + cls( + "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" + ), + color.map(_(600).bg), + div(id), + children + ) + + def count(label: String, value: Int, color: ColorDef): Span = + span(color.text, title(label), s"${value}") + + def counts(children: HtmlElement*): Span = + span(interleave(children, span(" / "))) + + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div = + div( + cls("flex flex-col mt-2"), + div( + cls( + "align-middle min-w-full shadow sm:rounded-lg" + ), + L.table( + cls("min-w-full border-separate border-spacing-0"), + styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work + thead( + tr( + cls("border-t border-gray-200"), + headers.map(h => + th( + cls( + "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + h + ) + ) + ) + ), + tbody(cls("bg-white divide-y divide-gray-100"), sections) + ) + ) + ) + + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + td( + cls("bg-gray-50 border-gray-200 border-t border-b"), + cls( + "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + colSpan(cs), + label + ), + mods + ) + + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + mods, + td( + cls( + "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" + ), + label + ), + children.map(e => + td( + cls( + "whitespace-nowrap px-2 py-2 text-sm text-gray-500" + ), + e + ) + ) + ) + + private def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala new file mode 100644 index 0000000..4cff836 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala @@ -0,0 +1,56 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +import zio.* +import com.raquo.airstream.core.Observer +import scala.annotation.implicitNotFound + +trait EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] + +object EffectHandler: + given zioEffectHandler[Env, E, A](using + Runtime[Env] + ): Conversion[ZIOEffectHandler[Env, E, A], EffectHandler[E, A]] with + def apply(h: ZIOEffectHandler[Env, E, A]): EffectHandler[E, A] = + LaminarZIOEffectHandler(h) + + def loggingHandler[E, A](name: String)( + underlying: EffectHandler[E, A] + ): EffectHandler[E, A] = + new EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + underlying( + effects.debugWithName(s"$name effects").debugLog(), + actions.debugWithName(s"$name actions").debugLog() + ) + +class LaminarZIOEffectHandler[Env, E, A](handler: ZIOEffectHandler[Env, E, A])( + using runtime: Runtime[Env] +) extends EffectHandler[E, A]: + + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + onMountCallback(ctx => + effects.foreach { effect => + Unsafe.unsafe { implicit unsafe => + runtime.unsafe + .runToFuture( + handler.handle(effect).either.runForeach { + case Right(a) => ZIO.succeed(actions.onNext(a)) + case Left(e) => ZIO.succeed(actions.onError(e)) + } + ) + } + }(ctx.owner) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala new file mode 100644 index 0000000..df42132 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala @@ -0,0 +1,97 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.raquo.laminar.keys.ReactiveProp +import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent +import org.scalajs.dom.html +import com.raquo.laminar.modifiers.KeyUpdater + +trait FormComponentsModule extends LocalDateSelectModule: + def forms: FormComponents + + trait FormComponents: + def form(mods: Modifier[HtmlElement]*): HtmlElement + + def searchField(id: String, placeholderText: Option[String] = None)( + mods: Modifier[HtmlElement]* + ): HtmlElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement + +trait DefaultFormComponentsModule(using ctx: ComponentContext) + extends FormComponentsModule: + self: IconsModule => + override val forms = new FormComponents: + + override def form( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + override def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label( + forId := id, + cls := "sr-only", + "Hledat" + ), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + icons.`search-solid`() + ), + input( + tpe := "search", + name := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) + + override def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + name(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala new file mode 100644 index 0000000..32fda8a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala @@ -0,0 +1,109 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.CustomAttrs + +trait IconsModule: + def icons: Icons + + trait Icons: + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement + def close(mods: Modifier[SvgElement]*): SvgElement + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement + def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement + +trait DefaultIconsModule(using ComponentContext) extends IconsModule: + override val icons: Icons = new Icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("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" + ) + ) + ) + + override def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala new file mode 100644 index 0000000..f7bacb1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -0,0 +1,35 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +abstract class LaminarComponent[M, A, E]( + effectHandler: EffectHandler[E, A] +) extends Module[M, A, E]: + def render(m: Signal[M], actions: Observer[A]): HtmlElement + + val element: HtmlElement = + val actions = new EventBus[A] + + val zero @ (_, effect) = init + + val initialEffect$ = (effect match + case Some(e) => EventStream.fromValue(e) + case _ => EventStream.empty + ) + + val actions$ = actions.events.recover(handleFailure) + + val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + handle(a, m) + } + + val nextEffects$ = processor$.changes.collect { case (_, Some(e)) => e } + + val model$ = processor$.map(_._1) + + val effect$ = EventStream.merge(initialEffect$, nextEffects$) + + render(model$, actions.writer).amend( + effectHandler(effect$, actions.writer) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala new file mode 100644 index 0000000..98e31b9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala @@ -0,0 +1,114 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html.Paragraph +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.nodes.TextNode + +trait ListComponentsModule: + + def list: ListComponents + + trait ListComponents(using ComponentContext): + def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] + def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None + ): Li + def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] + def listSection(header: String, list: HtmlElement): Div + def navigation(sections: Modifier[HtmlElement]): HtmlElement + +trait DefaultListComponentsModule(using ComponentContext) + extends ListComponentsModule: + override val list: ListComponents = new ListComponents: + override def label( + text: String, + color: ColorKind + ): ReactiveHtmlElement[Paragraph] = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + text + ) + + override def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None + ): Li = + li( + cls("group"), + div( + cls( + "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" + ), + avatar.map(a => + div( + cls("flex-shrink-0"), + div( + cls( + "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" + ), + a + ) + ) + ), + div( + cls("flex-1 min-w-0"), + p( + cls("text-sm font-medium text-gray-900"), + title, + span(cls("float-right"), right) + ), + subtitle.map(st => + p( + cls("text-sm text-gray-500 truncate"), + st + ) + ) + ) + ) + ) + + override def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = + ul( + cls("relative z-0 divide-y divide-gray-200"), + role("list"), + children + ) + + override def listSection( + header: String, + list: HtmlElement + ): Div = + div( + cls("relative"), + div( + 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 + ), + list + ) + + override def navigation(sections: Modifier[HtmlElement]): HtmlElement = + nav( + cls("flex-1 min-h-0 overflow-y-auto"), + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala new file mode 100644 index 0000000..31d860d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -0,0 +1,72 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.raquo.laminar.keys.ReactiveProp +import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent +import org.scalajs.dom.html +import com.raquo.laminar.modifiers.KeyUpdater + +trait LocalDateSelectModule: + val localDateSelect: LocalDateSelect = new LocalDateSelect + + class LocalDateSelect: + import LocalDateSelect.* + + def valueUpdater( + signal: Signal[Option[LocalDate]] + ): KeyUpdater.PropUpdater[String, String] = + L.value <-- signal.map(_.map(formatDate).getOrElse("")) + + // Does not work in `controlled` + // Laminar refuses the custom prop, requries its own `value` or `checked` + val value: ReactiveProp[Option[LocalDate], String] = + customProp("value", OptLocalDateAsStringCodec) + + val min: ReactiveProp[LocalDate, String] = + customProp("min", LocalDateAsStringCodec) + + val max: ReactiveProp[LocalDate, String] = + customProp("max", LocalDateAsStringCodec) + + val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => + d + } + + val onOptInput + : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + onInput.mapToValue.setAsValue.map(parseDate) + + object LocalDateSelect: + import java.time.format.DateTimeFormatter + import java.time.LocalDate + import com.raquo.domtypes.generic.codecs.Codec + + private val formatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd") + + private def parseDate(date: String): Option[LocalDate] = + import scala.util.Try + if date.isEmpty then None + else Try(LocalDate.parse(date, formatter)).toOption + + private def formatDate(date: LocalDate): String = + formatter.format(date) + + object LocalDateAsStringCodec extends Codec[LocalDate, String]: + override def decode(domValue: String): LocalDate = + parseDate(domValue).orNull + + override def encode(scalaValue: LocalDate): String = + formatDate(scalaValue) + + object OptLocalDateAsStringCodec extends Codec[Option[LocalDate], String]: + override def decode(domValue: String): Option[LocalDate] = + parseDate(domValue) + + override def encode(scalaValue: Option[LocalDate]): String = + scalaValue.map(formatDate).getOrElse("") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala new file mode 100644 index 0000000..3f3e7d0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala @@ -0,0 +1,71 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait PageComponentsModule: + + val page: PageComponents + + trait PageComponents: + def container( + children: Modifier[HtmlElement]* + ): HtmlElement + + def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement + + def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement] = emptyMod, + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement + + /** Visage for clickable text, like links + */ + def clickable: Modifier[HtmlElement] + +trait DefaultPageComponentsModule(using ComponentContext) + extends PageComponentsModule: + + override val page: PageComponents = new PageComponents: + override def container( + children: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), + children + ) + + override def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement = + div( + cls("p-8 bg-gray-100 h-full"), + header, + children + ) + + override def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + div( + cls("pb-5 border-b border-gray-200"), + div(cls("float-right"), right), + h1( + cls("text-2xl leading-6 font-medium text-gray-900"), + title + ), + subtitle.map( + p( + cls("text-sm font-medium text-gray-500"), + _ + ) + ) + ) + + override def clickable: Modifier[HtmlElement] = + cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") 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 index 6cda48b..f23f0e8 100644 --- 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 @@ -9,7 +9,7 @@ enum DisplayClass: case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` + `inline-table`, `table-caption` object ShowUpFrom: inline def apply( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala new file mode 100644 index 0000000..d2e0b4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag + +case class Bin[Source, +Value]( + label: String, + description: Option[String | HtmlElement], + color: ColorDef, + valueOf: Source => Value +): + def map[NewValue]( + f: Bin[Source, Value] => Source => NewValue + ): Bin[Source, NewValue] = + copy(valueOf = f(this)) + +case class Bins[T, U](bins: Seq[Bin[T, U]]): + def apply(v: T): Seq[U] = bins.map(_.valueOf(v)) + def map[A](r: Bin[T, U] => T => A): Bins[T, A] = + copy(bins = bins.map(_.map(r))) + + def renderSeparated( + r: Bin[T, U] => T => HtmlElement, + separator: => HtmlElement = span(" / "), + container: HtmlTag[org.scalajs.dom.html.Element] = span + )(v: T): HtmlElement = + container(interleave(map(r)(v), separator)) + + def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala new file mode 100644 index 0000000..a47f5ba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala @@ -0,0 +1,45 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait ButtonComponentsModule: + def buttons: ButtonComponents + + trait ButtonComponents: + def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + +trait DefaultButtonComponentsModule(using ctx: ComponentContext) + extends ButtonComponentsModule: + + override val buttons = new ButtonComponents: + + private inline def srHelp(id: String): Modifier[HtmlElement] = + ctx.messages + .opt(s"form.button.${id}.screenReaderHelp") + .map(sr => span(cls := "sr-only", sr)) + + override def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srHelp(id), + icon + ) + + override def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srHelp(id) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala new file mode 100644 index 0000000..15169f3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala @@ -0,0 +1,191 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom.html.UList +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind + +trait DashboardComponentsModule: + def dashboard: DashboardComponents + + // Only methods that create HtmlElements + trait DashboardComponents(using ComponentContext): + def section(label: String, children: Modifier[HtmlElement]): Div + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li + def cardInitials( + initials: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div + def count(label: String, value: Int, color: ColorDef): Span + def counts(children: HtmlElement*): Span + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + +trait DefaultDashboardComponentsModule(using ComponentContext) + extends DashboardComponentsModule: + override val dashboard: DashboardComponents = new DashboardComponents: + def section(label: String, children: Modifier[HtmlElement]): Div = + div( + cls("mt-3"), + h2( + cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), + label + ), + children + ) + + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = + ul( + cls( + "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + ), + children + ) + + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li = + li( + div( + cls("col-span-1 flex shadow-sm rounded-md"), + initials, + 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"), + div( + cls("text-gray-900 font-medium hover:text-gray-600 truncate"), + label + ), + p(cls("text-gray-500"), children) + ) + ) + ) + ) + + def cardInitials( + id: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div = + div( + cls( + "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" + ), + color.map(_(600).bg), + div(id), + children + ) + + def count(label: String, value: Int, color: ColorDef): Span = + span(color.text, title(label), s"${value}") + + def counts(children: HtmlElement*): Span = + span(interleave(children, span(" / "))) + + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div = + div( + cls("flex flex-col mt-2"), + div( + cls( + "align-middle min-w-full shadow sm:rounded-lg" + ), + L.table( + cls("min-w-full border-separate border-spacing-0"), + styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work + thead( + tr( + cls("border-t border-gray-200"), + headers.map(h => + th( + cls( + "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + h + ) + ) + ) + ), + tbody(cls("bg-white divide-y divide-gray-100"), sections) + ) + ) + ) + + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + td( + cls("bg-gray-50 border-gray-200 border-t border-b"), + cls( + "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + colSpan(cs), + label + ), + mods + ) + + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + mods, + td( + cls( + "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" + ), + label + ), + children.map(e => + td( + cls( + "whitespace-nowrap px-2 py-2 text-sm text-gray-500" + ), + e + ) + ) + ) + + private def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala new file mode 100644 index 0000000..4cff836 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala @@ -0,0 +1,56 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +import zio.* +import com.raquo.airstream.core.Observer +import scala.annotation.implicitNotFound + +trait EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] + +object EffectHandler: + given zioEffectHandler[Env, E, A](using + Runtime[Env] + ): Conversion[ZIOEffectHandler[Env, E, A], EffectHandler[E, A]] with + def apply(h: ZIOEffectHandler[Env, E, A]): EffectHandler[E, A] = + LaminarZIOEffectHandler(h) + + def loggingHandler[E, A](name: String)( + underlying: EffectHandler[E, A] + ): EffectHandler[E, A] = + new EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + underlying( + effects.debugWithName(s"$name effects").debugLog(), + actions.debugWithName(s"$name actions").debugLog() + ) + +class LaminarZIOEffectHandler[Env, E, A](handler: ZIOEffectHandler[Env, E, A])( + using runtime: Runtime[Env] +) extends EffectHandler[E, A]: + + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + onMountCallback(ctx => + effects.foreach { effect => + Unsafe.unsafe { implicit unsafe => + runtime.unsafe + .runToFuture( + handler.handle(effect).either.runForeach { + case Right(a) => ZIO.succeed(actions.onNext(a)) + case Left(e) => ZIO.succeed(actions.onError(e)) + } + ) + } + }(ctx.owner) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala new file mode 100644 index 0000000..df42132 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala @@ -0,0 +1,97 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.raquo.laminar.keys.ReactiveProp +import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent +import org.scalajs.dom.html +import com.raquo.laminar.modifiers.KeyUpdater + +trait FormComponentsModule extends LocalDateSelectModule: + def forms: FormComponents + + trait FormComponents: + def form(mods: Modifier[HtmlElement]*): HtmlElement + + def searchField(id: String, placeholderText: Option[String] = None)( + mods: Modifier[HtmlElement]* + ): HtmlElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement + +trait DefaultFormComponentsModule(using ctx: ComponentContext) + extends FormComponentsModule: + self: IconsModule => + override val forms = new FormComponents: + + override def form( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + override def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label( + forId := id, + cls := "sr-only", + "Hledat" + ), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + icons.`search-solid`() + ), + input( + tpe := "search", + name := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) + + override def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + name(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala new file mode 100644 index 0000000..32fda8a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala @@ -0,0 +1,109 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.CustomAttrs + +trait IconsModule: + def icons: Icons + + trait Icons: + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement + def close(mods: Modifier[SvgElement]*): SvgElement + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement + def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement + +trait DefaultIconsModule(using ComponentContext) extends IconsModule: + override val icons: Icons = new Icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("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" + ) + ) + ) + + override def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala new file mode 100644 index 0000000..f7bacb1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -0,0 +1,35 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +abstract class LaminarComponent[M, A, E]( + effectHandler: EffectHandler[E, A] +) extends Module[M, A, E]: + def render(m: Signal[M], actions: Observer[A]): HtmlElement + + val element: HtmlElement = + val actions = new EventBus[A] + + val zero @ (_, effect) = init + + val initialEffect$ = (effect match + case Some(e) => EventStream.fromValue(e) + case _ => EventStream.empty + ) + + val actions$ = actions.events.recover(handleFailure) + + val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + handle(a, m) + } + + val nextEffects$ = processor$.changes.collect { case (_, Some(e)) => e } + + val model$ = processor$.map(_._1) + + val effect$ = EventStream.merge(initialEffect$, nextEffects$) + + render(model$, actions.writer).amend( + effectHandler(effect$, actions.writer) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala new file mode 100644 index 0000000..98e31b9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala @@ -0,0 +1,114 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html.Paragraph +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.nodes.TextNode + +trait ListComponentsModule: + + def list: ListComponents + + trait ListComponents(using ComponentContext): + def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] + def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None + ): Li + def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] + def listSection(header: String, list: HtmlElement): Div + def navigation(sections: Modifier[HtmlElement]): HtmlElement + +trait DefaultListComponentsModule(using ComponentContext) + extends ListComponentsModule: + override val list: ListComponents = new ListComponents: + override def label( + text: String, + color: ColorKind + ): ReactiveHtmlElement[Paragraph] = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + text + ) + + override def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None + ): Li = + li( + cls("group"), + div( + cls( + "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" + ), + avatar.map(a => + div( + cls("flex-shrink-0"), + div( + cls( + "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" + ), + a + ) + ) + ), + div( + cls("flex-1 min-w-0"), + p( + cls("text-sm font-medium text-gray-900"), + title, + span(cls("float-right"), right) + ), + subtitle.map(st => + p( + cls("text-sm text-gray-500 truncate"), + st + ) + ) + ) + ) + ) + + override def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = + ul( + cls("relative z-0 divide-y divide-gray-200"), + role("list"), + children + ) + + override def listSection( + header: String, + list: HtmlElement + ): Div = + div( + cls("relative"), + div( + 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 + ), + list + ) + + override def navigation(sections: Modifier[HtmlElement]): HtmlElement = + nav( + cls("flex-1 min-h-0 overflow-y-auto"), + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala new file mode 100644 index 0000000..31d860d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -0,0 +1,72 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.raquo.laminar.keys.ReactiveProp +import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent +import org.scalajs.dom.html +import com.raquo.laminar.modifiers.KeyUpdater + +trait LocalDateSelectModule: + val localDateSelect: LocalDateSelect = new LocalDateSelect + + class LocalDateSelect: + import LocalDateSelect.* + + def valueUpdater( + signal: Signal[Option[LocalDate]] + ): KeyUpdater.PropUpdater[String, String] = + L.value <-- signal.map(_.map(formatDate).getOrElse("")) + + // Does not work in `controlled` + // Laminar refuses the custom prop, requries its own `value` or `checked` + val value: ReactiveProp[Option[LocalDate], String] = + customProp("value", OptLocalDateAsStringCodec) + + val min: ReactiveProp[LocalDate, String] = + customProp("min", LocalDateAsStringCodec) + + val max: ReactiveProp[LocalDate, String] = + customProp("max", LocalDateAsStringCodec) + + val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => + d + } + + val onOptInput + : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + onInput.mapToValue.setAsValue.map(parseDate) + + object LocalDateSelect: + import java.time.format.DateTimeFormatter + import java.time.LocalDate + import com.raquo.domtypes.generic.codecs.Codec + + private val formatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd") + + private def parseDate(date: String): Option[LocalDate] = + import scala.util.Try + if date.isEmpty then None + else Try(LocalDate.parse(date, formatter)).toOption + + private def formatDate(date: LocalDate): String = + formatter.format(date) + + object LocalDateAsStringCodec extends Codec[LocalDate, String]: + override def decode(domValue: String): LocalDate = + parseDate(domValue).orNull + + override def encode(scalaValue: LocalDate): String = + formatDate(scalaValue) + + object OptLocalDateAsStringCodec extends Codec[Option[LocalDate], String]: + override def decode(domValue: String): Option[LocalDate] = + parseDate(domValue) + + override def encode(scalaValue: Option[LocalDate]): String = + scalaValue.map(formatDate).getOrElse("") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala new file mode 100644 index 0000000..3f3e7d0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala @@ -0,0 +1,71 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait PageComponentsModule: + + val page: PageComponents + + trait PageComponents: + def container( + children: Modifier[HtmlElement]* + ): HtmlElement + + def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement + + def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement] = emptyMod, + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement + + /** Visage for clickable text, like links + */ + def clickable: Modifier[HtmlElement] + +trait DefaultPageComponentsModule(using ComponentContext) + extends PageComponentsModule: + + override val page: PageComponents = new PageComponents: + override def container( + children: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), + children + ) + + override def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement = + div( + cls("p-8 bg-gray-100 h-full"), + header, + children + ) + + override def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + div( + cls("pb-5 border-b border-gray-200"), + div(cls("float-right"), right), + h1( + cls("text-2xl leading-6 font-medium text-gray-900"), + title + ), + subtitle.map( + p( + cls("text-sm font-medium text-gray-500"), + _ + ) + ) + ) + + override def clickable: Modifier[HtmlElement] = + cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") 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 index 6cda48b..f23f0e8 100644 --- 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 @@ -9,7 +9,7 @@ enum DisplayClass: case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` + `inline-table`, `table-caption` object ShowUpFrom: inline def apply( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/experimental/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/experimental/Color.scala new file mode 100644 index 0000000..45261a7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/experimental/Color.scala @@ -0,0 +1,162 @@ +package works.iterative.ui.components.tailwind.experimental + +import scala.util.NotGiven +import com.raquo.domtypes.generic.Modifier +import org.scalajs.dom.SVGElement + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" + +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") + +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") + +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) + +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala new file mode 100644 index 0000000..d2e0b4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag + +case class Bin[Source, +Value]( + label: String, + description: Option[String | HtmlElement], + color: ColorDef, + valueOf: Source => Value +): + def map[NewValue]( + f: Bin[Source, Value] => Source => NewValue + ): Bin[Source, NewValue] = + copy(valueOf = f(this)) + +case class Bins[T, U](bins: Seq[Bin[T, U]]): + def apply(v: T): Seq[U] = bins.map(_.valueOf(v)) + def map[A](r: Bin[T, U] => T => A): Bins[T, A] = + copy(bins = bins.map(_.map(r))) + + def renderSeparated( + r: Bin[T, U] => T => HtmlElement, + separator: => HtmlElement = span(" / "), + container: HtmlTag[org.scalajs.dom.html.Element] = span + )(v: T): HtmlElement = + container(interleave(map(r)(v), separator)) + + def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala new file mode 100644 index 0000000..a47f5ba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala @@ -0,0 +1,45 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait ButtonComponentsModule: + def buttons: ButtonComponents + + trait ButtonComponents: + def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + +trait DefaultButtonComponentsModule(using ctx: ComponentContext) + extends ButtonComponentsModule: + + override val buttons = new ButtonComponents: + + private inline def srHelp(id: String): Modifier[HtmlElement] = + ctx.messages + .opt(s"form.button.${id}.screenReaderHelp") + .map(sr => span(cls := "sr-only", sr)) + + override def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srHelp(id), + icon + ) + + override def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srHelp(id) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala new file mode 100644 index 0000000..15169f3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala @@ -0,0 +1,191 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom.html.UList +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind + +trait DashboardComponentsModule: + def dashboard: DashboardComponents + + // Only methods that create HtmlElements + trait DashboardComponents(using ComponentContext): + def section(label: String, children: Modifier[HtmlElement]): Div + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li + def cardInitials( + initials: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div + def count(label: String, value: Int, color: ColorDef): Span + def counts(children: HtmlElement*): Span + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + +trait DefaultDashboardComponentsModule(using ComponentContext) + extends DashboardComponentsModule: + override val dashboard: DashboardComponents = new DashboardComponents: + def section(label: String, children: Modifier[HtmlElement]): Div = + div( + cls("mt-3"), + h2( + cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), + label + ), + children + ) + + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = + ul( + cls( + "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + ), + children + ) + + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li = + li( + div( + cls("col-span-1 flex shadow-sm rounded-md"), + initials, + 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"), + div( + cls("text-gray-900 font-medium hover:text-gray-600 truncate"), + label + ), + p(cls("text-gray-500"), children) + ) + ) + ) + ) + + def cardInitials( + id: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div = + div( + cls( + "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" + ), + color.map(_(600).bg), + div(id), + children + ) + + def count(label: String, value: Int, color: ColorDef): Span = + span(color.text, title(label), s"${value}") + + def counts(children: HtmlElement*): Span = + span(interleave(children, span(" / "))) + + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div = + div( + cls("flex flex-col mt-2"), + div( + cls( + "align-middle min-w-full shadow sm:rounded-lg" + ), + L.table( + cls("min-w-full border-separate border-spacing-0"), + styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work + thead( + tr( + cls("border-t border-gray-200"), + headers.map(h => + th( + cls( + "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + h + ) + ) + ) + ), + tbody(cls("bg-white divide-y divide-gray-100"), sections) + ) + ) + ) + + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + td( + cls("bg-gray-50 border-gray-200 border-t border-b"), + cls( + "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + colSpan(cs), + label + ), + mods + ) + + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + mods, + td( + cls( + "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" + ), + label + ), + children.map(e => + td( + cls( + "whitespace-nowrap px-2 py-2 text-sm text-gray-500" + ), + e + ) + ) + ) + + private def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala new file mode 100644 index 0000000..4cff836 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala @@ -0,0 +1,56 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +import zio.* +import com.raquo.airstream.core.Observer +import scala.annotation.implicitNotFound + +trait EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] + +object EffectHandler: + given zioEffectHandler[Env, E, A](using + Runtime[Env] + ): Conversion[ZIOEffectHandler[Env, E, A], EffectHandler[E, A]] with + def apply(h: ZIOEffectHandler[Env, E, A]): EffectHandler[E, A] = + LaminarZIOEffectHandler(h) + + def loggingHandler[E, A](name: String)( + underlying: EffectHandler[E, A] + ): EffectHandler[E, A] = + new EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + underlying( + effects.debugWithName(s"$name effects").debugLog(), + actions.debugWithName(s"$name actions").debugLog() + ) + +class LaminarZIOEffectHandler[Env, E, A](handler: ZIOEffectHandler[Env, E, A])( + using runtime: Runtime[Env] +) extends EffectHandler[E, A]: + + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + onMountCallback(ctx => + effects.foreach { effect => + Unsafe.unsafe { implicit unsafe => + runtime.unsafe + .runToFuture( + handler.handle(effect).either.runForeach { + case Right(a) => ZIO.succeed(actions.onNext(a)) + case Left(e) => ZIO.succeed(actions.onError(e)) + } + ) + } + }(ctx.owner) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala new file mode 100644 index 0000000..df42132 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala @@ -0,0 +1,97 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.raquo.laminar.keys.ReactiveProp +import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent +import org.scalajs.dom.html +import com.raquo.laminar.modifiers.KeyUpdater + +trait FormComponentsModule extends LocalDateSelectModule: + def forms: FormComponents + + trait FormComponents: + def form(mods: Modifier[HtmlElement]*): HtmlElement + + def searchField(id: String, placeholderText: Option[String] = None)( + mods: Modifier[HtmlElement]* + ): HtmlElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement + +trait DefaultFormComponentsModule(using ctx: ComponentContext) + extends FormComponentsModule: + self: IconsModule => + override val forms = new FormComponents: + + override def form( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + override def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label( + forId := id, + cls := "sr-only", + "Hledat" + ), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + icons.`search-solid`() + ), + input( + tpe := "search", + name := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) + + override def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + name(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala new file mode 100644 index 0000000..32fda8a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala @@ -0,0 +1,109 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.CustomAttrs + +trait IconsModule: + def icons: Icons + + trait Icons: + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement + def close(mods: Modifier[SvgElement]*): SvgElement + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement + def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement + +trait DefaultIconsModule(using ComponentContext) extends IconsModule: + override val icons: Icons = new Icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("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" + ) + ) + ) + + override def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala new file mode 100644 index 0000000..f7bacb1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -0,0 +1,35 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +abstract class LaminarComponent[M, A, E]( + effectHandler: EffectHandler[E, A] +) extends Module[M, A, E]: + def render(m: Signal[M], actions: Observer[A]): HtmlElement + + val element: HtmlElement = + val actions = new EventBus[A] + + val zero @ (_, effect) = init + + val initialEffect$ = (effect match + case Some(e) => EventStream.fromValue(e) + case _ => EventStream.empty + ) + + val actions$ = actions.events.recover(handleFailure) + + val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + handle(a, m) + } + + val nextEffects$ = processor$.changes.collect { case (_, Some(e)) => e } + + val model$ = processor$.map(_._1) + + val effect$ = EventStream.merge(initialEffect$, nextEffects$) + + render(model$, actions.writer).amend( + effectHandler(effect$, actions.writer) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala new file mode 100644 index 0000000..98e31b9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala @@ -0,0 +1,114 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html.Paragraph +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.nodes.TextNode + +trait ListComponentsModule: + + def list: ListComponents + + trait ListComponents(using ComponentContext): + def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] + def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None + ): Li + def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] + def listSection(header: String, list: HtmlElement): Div + def navigation(sections: Modifier[HtmlElement]): HtmlElement + +trait DefaultListComponentsModule(using ComponentContext) + extends ListComponentsModule: + override val list: ListComponents = new ListComponents: + override def label( + text: String, + color: ColorKind + ): ReactiveHtmlElement[Paragraph] = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + text + ) + + override def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None + ): Li = + li( + cls("group"), + div( + cls( + "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" + ), + avatar.map(a => + div( + cls("flex-shrink-0"), + div( + cls( + "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" + ), + a + ) + ) + ), + div( + cls("flex-1 min-w-0"), + p( + cls("text-sm font-medium text-gray-900"), + title, + span(cls("float-right"), right) + ), + subtitle.map(st => + p( + cls("text-sm text-gray-500 truncate"), + st + ) + ) + ) + ) + ) + + override def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = + ul( + cls("relative z-0 divide-y divide-gray-200"), + role("list"), + children + ) + + override def listSection( + header: String, + list: HtmlElement + ): Div = + div( + cls("relative"), + div( + 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 + ), + list + ) + + override def navigation(sections: Modifier[HtmlElement]): HtmlElement = + nav( + cls("flex-1 min-h-0 overflow-y-auto"), + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala new file mode 100644 index 0000000..31d860d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -0,0 +1,72 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.raquo.laminar.keys.ReactiveProp +import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent +import org.scalajs.dom.html +import com.raquo.laminar.modifiers.KeyUpdater + +trait LocalDateSelectModule: + val localDateSelect: LocalDateSelect = new LocalDateSelect + + class LocalDateSelect: + import LocalDateSelect.* + + def valueUpdater( + signal: Signal[Option[LocalDate]] + ): KeyUpdater.PropUpdater[String, String] = + L.value <-- signal.map(_.map(formatDate).getOrElse("")) + + // Does not work in `controlled` + // Laminar refuses the custom prop, requries its own `value` or `checked` + val value: ReactiveProp[Option[LocalDate], String] = + customProp("value", OptLocalDateAsStringCodec) + + val min: ReactiveProp[LocalDate, String] = + customProp("min", LocalDateAsStringCodec) + + val max: ReactiveProp[LocalDate, String] = + customProp("max", LocalDateAsStringCodec) + + val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => + d + } + + val onOptInput + : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + onInput.mapToValue.setAsValue.map(parseDate) + + object LocalDateSelect: + import java.time.format.DateTimeFormatter + import java.time.LocalDate + import com.raquo.domtypes.generic.codecs.Codec + + private val formatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd") + + private def parseDate(date: String): Option[LocalDate] = + import scala.util.Try + if date.isEmpty then None + else Try(LocalDate.parse(date, formatter)).toOption + + private def formatDate(date: LocalDate): String = + formatter.format(date) + + object LocalDateAsStringCodec extends Codec[LocalDate, String]: + override def decode(domValue: String): LocalDate = + parseDate(domValue).orNull + + override def encode(scalaValue: LocalDate): String = + formatDate(scalaValue) + + object OptLocalDateAsStringCodec extends Codec[Option[LocalDate], String]: + override def decode(domValue: String): Option[LocalDate] = + parseDate(domValue) + + override def encode(scalaValue: Option[LocalDate]): String = + scalaValue.map(formatDate).getOrElse("") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala new file mode 100644 index 0000000..3f3e7d0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala @@ -0,0 +1,71 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait PageComponentsModule: + + val page: PageComponents + + trait PageComponents: + def container( + children: Modifier[HtmlElement]* + ): HtmlElement + + def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement + + def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement] = emptyMod, + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement + + /** Visage for clickable text, like links + */ + def clickable: Modifier[HtmlElement] + +trait DefaultPageComponentsModule(using ComponentContext) + extends PageComponentsModule: + + override val page: PageComponents = new PageComponents: + override def container( + children: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), + children + ) + + override def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement = + div( + cls("p-8 bg-gray-100 h-full"), + header, + children + ) + + override def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + div( + cls("pb-5 border-b border-gray-200"), + div(cls("float-right"), right), + h1( + cls("text-2xl leading-6 font-medium text-gray-900"), + title + ), + subtitle.map( + p( + cls("text-sm font-medium text-gray-500"), + _ + ) + ) + ) + + override def clickable: Modifier[HtmlElement] = + cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") 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 index 6cda48b..f23f0e8 100644 --- 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 @@ -9,7 +9,7 @@ enum DisplayClass: case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` + `inline-table`, `table-caption` object ShowUpFrom: inline def apply( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/experimental/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/experimental/Color.scala new file mode 100644 index 0000000..45261a7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/experimental/Color.scala @@ -0,0 +1,162 @@ +package works.iterative.ui.components.tailwind.experimental + +import scala.util.NotGiven +import com.raquo.domtypes.generic.Modifier +import org.scalajs.dom.SVGElement + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" + +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") + +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") + +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) + +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..7777c7a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package laminar + +import com.raquo.laminar.api.L.{*, given} + +object LaminarExtensions: + given colorToCSS: Conversion[experimental.Color, Modifier[HtmlElement]] with + def apply(c: experimental.Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[experimental.Color, Modifier[SvgElement]] with + def apply(c: experimental.Color) = svg.cls(c.toCSS) + + given colorSignalToCSS + : Conversion[Signal[experimental.Color], Modifier[HtmlElement]] with + def apply(c: Signal[experimental.Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS + : Conversion[Signal[experimental.Color], Modifier[SvgElement]] with + def apply(c: Signal[experimental.Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala new file mode 100644 index 0000000..d2e0b4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag + +case class Bin[Source, +Value]( + label: String, + description: Option[String | HtmlElement], + color: ColorDef, + valueOf: Source => Value +): + def map[NewValue]( + f: Bin[Source, Value] => Source => NewValue + ): Bin[Source, NewValue] = + copy(valueOf = f(this)) + +case class Bins[T, U](bins: Seq[Bin[T, U]]): + def apply(v: T): Seq[U] = bins.map(_.valueOf(v)) + def map[A](r: Bin[T, U] => T => A): Bins[T, A] = + copy(bins = bins.map(_.map(r))) + + def renderSeparated( + r: Bin[T, U] => T => HtmlElement, + separator: => HtmlElement = span(" / "), + container: HtmlTag[org.scalajs.dom.html.Element] = span + )(v: T): HtmlElement = + container(interleave(map(r)(v), separator)) + + def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala new file mode 100644 index 0000000..a47f5ba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala @@ -0,0 +1,45 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait ButtonComponentsModule: + def buttons: ButtonComponents + + trait ButtonComponents: + def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + +trait DefaultButtonComponentsModule(using ctx: ComponentContext) + extends ButtonComponentsModule: + + override val buttons = new ButtonComponents: + + private inline def srHelp(id: String): Modifier[HtmlElement] = + ctx.messages + .opt(s"form.button.${id}.screenReaderHelp") + .map(sr => span(cls := "sr-only", sr)) + + override def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srHelp(id), + icon + ) + + override def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srHelp(id) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala new file mode 100644 index 0000000..15169f3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala @@ -0,0 +1,191 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom.html.UList +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind + +trait DashboardComponentsModule: + def dashboard: DashboardComponents + + // Only methods that create HtmlElements + trait DashboardComponents(using ComponentContext): + def section(label: String, children: Modifier[HtmlElement]): Div + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li + def cardInitials( + initials: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div + def count(label: String, value: Int, color: ColorDef): Span + def counts(children: HtmlElement*): Span + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + +trait DefaultDashboardComponentsModule(using ComponentContext) + extends DashboardComponentsModule: + override val dashboard: DashboardComponents = new DashboardComponents: + def section(label: String, children: Modifier[HtmlElement]): Div = + div( + cls("mt-3"), + h2( + cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), + label + ), + children + ) + + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = + ul( + cls( + "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + ), + children + ) + + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li = + li( + div( + cls("col-span-1 flex shadow-sm rounded-md"), + initials, + 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"), + div( + cls("text-gray-900 font-medium hover:text-gray-600 truncate"), + label + ), + p(cls("text-gray-500"), children) + ) + ) + ) + ) + + def cardInitials( + id: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div = + div( + cls( + "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" + ), + color.map(_(600).bg), + div(id), + children + ) + + def count(label: String, value: Int, color: ColorDef): Span = + span(color.text, title(label), s"${value}") + + def counts(children: HtmlElement*): Span = + span(interleave(children, span(" / "))) + + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div = + div( + cls("flex flex-col mt-2"), + div( + cls( + "align-middle min-w-full shadow sm:rounded-lg" + ), + L.table( + cls("min-w-full border-separate border-spacing-0"), + styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work + thead( + tr( + cls("border-t border-gray-200"), + headers.map(h => + th( + cls( + "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + h + ) + ) + ) + ), + tbody(cls("bg-white divide-y divide-gray-100"), sections) + ) + ) + ) + + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + td( + cls("bg-gray-50 border-gray-200 border-t border-b"), + cls( + "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + colSpan(cs), + label + ), + mods + ) + + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + mods, + td( + cls( + "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" + ), + label + ), + children.map(e => + td( + cls( + "whitespace-nowrap px-2 py-2 text-sm text-gray-500" + ), + e + ) + ) + ) + + private def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala new file mode 100644 index 0000000..4cff836 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala @@ -0,0 +1,56 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +import zio.* +import com.raquo.airstream.core.Observer +import scala.annotation.implicitNotFound + +trait EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] + +object EffectHandler: + given zioEffectHandler[Env, E, A](using + Runtime[Env] + ): Conversion[ZIOEffectHandler[Env, E, A], EffectHandler[E, A]] with + def apply(h: ZIOEffectHandler[Env, E, A]): EffectHandler[E, A] = + LaminarZIOEffectHandler(h) + + def loggingHandler[E, A](name: String)( + underlying: EffectHandler[E, A] + ): EffectHandler[E, A] = + new EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + underlying( + effects.debugWithName(s"$name effects").debugLog(), + actions.debugWithName(s"$name actions").debugLog() + ) + +class LaminarZIOEffectHandler[Env, E, A](handler: ZIOEffectHandler[Env, E, A])( + using runtime: Runtime[Env] +) extends EffectHandler[E, A]: + + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + onMountCallback(ctx => + effects.foreach { effect => + Unsafe.unsafe { implicit unsafe => + runtime.unsafe + .runToFuture( + handler.handle(effect).either.runForeach { + case Right(a) => ZIO.succeed(actions.onNext(a)) + case Left(e) => ZIO.succeed(actions.onError(e)) + } + ) + } + }(ctx.owner) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala new file mode 100644 index 0000000..df42132 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala @@ -0,0 +1,97 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.raquo.laminar.keys.ReactiveProp +import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent +import org.scalajs.dom.html +import com.raquo.laminar.modifiers.KeyUpdater + +trait FormComponentsModule extends LocalDateSelectModule: + def forms: FormComponents + + trait FormComponents: + def form(mods: Modifier[HtmlElement]*): HtmlElement + + def searchField(id: String, placeholderText: Option[String] = None)( + mods: Modifier[HtmlElement]* + ): HtmlElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement + +trait DefaultFormComponentsModule(using ctx: ComponentContext) + extends FormComponentsModule: + self: IconsModule => + override val forms = new FormComponents: + + override def form( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + override def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label( + forId := id, + cls := "sr-only", + "Hledat" + ), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + icons.`search-solid`() + ), + input( + tpe := "search", + name := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) + + override def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + name(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala new file mode 100644 index 0000000..32fda8a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala @@ -0,0 +1,109 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.CustomAttrs + +trait IconsModule: + def icons: Icons + + trait Icons: + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement + def close(mods: Modifier[SvgElement]*): SvgElement + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement + def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement + +trait DefaultIconsModule(using ComponentContext) extends IconsModule: + override val icons: Icons = new Icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("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" + ) + ) + ) + + override def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala new file mode 100644 index 0000000..f7bacb1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -0,0 +1,35 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +abstract class LaminarComponent[M, A, E]( + effectHandler: EffectHandler[E, A] +) extends Module[M, A, E]: + def render(m: Signal[M], actions: Observer[A]): HtmlElement + + val element: HtmlElement = + val actions = new EventBus[A] + + val zero @ (_, effect) = init + + val initialEffect$ = (effect match + case Some(e) => EventStream.fromValue(e) + case _ => EventStream.empty + ) + + val actions$ = actions.events.recover(handleFailure) + + val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + handle(a, m) + } + + val nextEffects$ = processor$.changes.collect { case (_, Some(e)) => e } + + val model$ = processor$.map(_._1) + + val effect$ = EventStream.merge(initialEffect$, nextEffects$) + + render(model$, actions.writer).amend( + effectHandler(effect$, actions.writer) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala new file mode 100644 index 0000000..98e31b9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala @@ -0,0 +1,114 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html.Paragraph +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.nodes.TextNode + +trait ListComponentsModule: + + def list: ListComponents + + trait ListComponents(using ComponentContext): + def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] + def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None + ): Li + def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] + def listSection(header: String, list: HtmlElement): Div + def navigation(sections: Modifier[HtmlElement]): HtmlElement + +trait DefaultListComponentsModule(using ComponentContext) + extends ListComponentsModule: + override val list: ListComponents = new ListComponents: + override def label( + text: String, + color: ColorKind + ): ReactiveHtmlElement[Paragraph] = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + text + ) + + override def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None + ): Li = + li( + cls("group"), + div( + cls( + "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" + ), + avatar.map(a => + div( + cls("flex-shrink-0"), + div( + cls( + "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" + ), + a + ) + ) + ), + div( + cls("flex-1 min-w-0"), + p( + cls("text-sm font-medium text-gray-900"), + title, + span(cls("float-right"), right) + ), + subtitle.map(st => + p( + cls("text-sm text-gray-500 truncate"), + st + ) + ) + ) + ) + ) + + override def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = + ul( + cls("relative z-0 divide-y divide-gray-200"), + role("list"), + children + ) + + override def listSection( + header: String, + list: HtmlElement + ): Div = + div( + cls("relative"), + div( + 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 + ), + list + ) + + override def navigation(sections: Modifier[HtmlElement]): HtmlElement = + nav( + cls("flex-1 min-h-0 overflow-y-auto"), + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala new file mode 100644 index 0000000..31d860d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -0,0 +1,72 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.raquo.laminar.keys.ReactiveProp +import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent +import org.scalajs.dom.html +import com.raquo.laminar.modifiers.KeyUpdater + +trait LocalDateSelectModule: + val localDateSelect: LocalDateSelect = new LocalDateSelect + + class LocalDateSelect: + import LocalDateSelect.* + + def valueUpdater( + signal: Signal[Option[LocalDate]] + ): KeyUpdater.PropUpdater[String, String] = + L.value <-- signal.map(_.map(formatDate).getOrElse("")) + + // Does not work in `controlled` + // Laminar refuses the custom prop, requries its own `value` or `checked` + val value: ReactiveProp[Option[LocalDate], String] = + customProp("value", OptLocalDateAsStringCodec) + + val min: ReactiveProp[LocalDate, String] = + customProp("min", LocalDateAsStringCodec) + + val max: ReactiveProp[LocalDate, String] = + customProp("max", LocalDateAsStringCodec) + + val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => + d + } + + val onOptInput + : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + onInput.mapToValue.setAsValue.map(parseDate) + + object LocalDateSelect: + import java.time.format.DateTimeFormatter + import java.time.LocalDate + import com.raquo.domtypes.generic.codecs.Codec + + private val formatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd") + + private def parseDate(date: String): Option[LocalDate] = + import scala.util.Try + if date.isEmpty then None + else Try(LocalDate.parse(date, formatter)).toOption + + private def formatDate(date: LocalDate): String = + formatter.format(date) + + object LocalDateAsStringCodec extends Codec[LocalDate, String]: + override def decode(domValue: String): LocalDate = + parseDate(domValue).orNull + + override def encode(scalaValue: LocalDate): String = + formatDate(scalaValue) + + object OptLocalDateAsStringCodec extends Codec[Option[LocalDate], String]: + override def decode(domValue: String): Option[LocalDate] = + parseDate(domValue) + + override def encode(scalaValue: Option[LocalDate]): String = + scalaValue.map(formatDate).getOrElse("") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala new file mode 100644 index 0000000..3f3e7d0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala @@ -0,0 +1,71 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait PageComponentsModule: + + val page: PageComponents + + trait PageComponents: + def container( + children: Modifier[HtmlElement]* + ): HtmlElement + + def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement + + def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement] = emptyMod, + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement + + /** Visage for clickable text, like links + */ + def clickable: Modifier[HtmlElement] + +trait DefaultPageComponentsModule(using ComponentContext) + extends PageComponentsModule: + + override val page: PageComponents = new PageComponents: + override def container( + children: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), + children + ) + + override def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement = + div( + cls("p-8 bg-gray-100 h-full"), + header, + children + ) + + override def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + div( + cls("pb-5 border-b border-gray-200"), + div(cls("float-right"), right), + h1( + cls("text-2xl leading-6 font-medium text-gray-900"), + title + ), + subtitle.map( + p( + cls("text-sm font-medium text-gray-500"), + _ + ) + ) + ) + + override def clickable: Modifier[HtmlElement] = + cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") 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 index 6cda48b..f23f0e8 100644 --- 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 @@ -9,7 +9,7 @@ enum DisplayClass: case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` + `inline-table`, `table-caption` object ShowUpFrom: inline def apply( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/experimental/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/experimental/Color.scala new file mode 100644 index 0000000..45261a7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/experimental/Color.scala @@ -0,0 +1,162 @@ +package works.iterative.ui.components.tailwind.experimental + +import scala.util.NotGiven +import com.raquo.domtypes.generic.Modifier +import org.scalajs.dom.SVGElement + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" + +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") + +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") + +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) + +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..7777c7a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package laminar + +import com.raquo.laminar.api.L.{*, given} + +object LaminarExtensions: + given colorToCSS: Conversion[experimental.Color, Modifier[HtmlElement]] with + def apply(c: experimental.Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[experimental.Color, Modifier[SvgElement]] with + def apply(c: experimental.Color) = svg.cls(c.toCSS) + + given colorSignalToCSS + : Conversion[Signal[experimental.Color], Modifier[HtmlElement]] with + def apply(c: Signal[experimental.Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS + : Conversion[Signal[experimental.Color], Modifier[SvgElement]] with + def apply(c: Signal[experimental.Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala new file mode 100644 index 0000000..6e005af --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala @@ -0,0 +1,78 @@ +package works.iterative.ui +package scenarios + +import com.raquo.laminar.api.L + +import scala.scalajs.js.annotation.{JSExportTopLevel, JSImport} +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom +import org.scalajs.dom.html + +import scala.scalajs.js +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.StyleGuide +import works.iterative.ui.components.tailwind.experimental.Color +import works.iterative.ui.components.tailwind.experimental.ColorWeight + +object Scenario: + type Id = String + +trait Scenario: + trait ScenarioContext: + def events: Observer[Any] + + def id: Scenario.Id + + def label: String + + def element(using ComponentContext): HtmlElement + +trait ScenarioExample: + def title: String + def element(using ComponentContext): HtmlElement + +object ScenarioExample: + def apply( + t: String, + elem: ComponentContext ?=> HtmlElement + ): ScenarioExample = + new ScenarioExample: + override val title: String = t + override def element(using ComponentContext): HtmlElement = elem + +trait ScenarioExamples: + self: Scenario => + + protected def examples(using + ScenarioContext, + ComponentContext + ): List[ScenarioExample] + + override def element(using ComponentContext): HtmlElement = + val eventBus: EventBus[Any] = EventBus[Any]() + + given sc: ScenarioContext = new ScenarioContext: + def events: Observer[Any] = eventBus.writer + + div( + cls("flex flex-col space-y-5"), + eventBus.events --> { e => + org.scalajs.dom.console.log(s"action: ${e.toString}") + }, + examples.map(se => example(se.title, se.element)) + ) + + def example(t: String, c: HtmlElement): Div = + div( + cls("bg-white overflow-hidden shadow rounded-lg"), + div( + cls("px-4 py-5 sm:p-6"), + h3( + cls("text-lg leading-6 font-medium text-gray-900 border-b"), + t + ), + div(cls("px-5 py-5"), c) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala new file mode 100644 index 0000000..d2e0b4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag + +case class Bin[Source, +Value]( + label: String, + description: Option[String | HtmlElement], + color: ColorDef, + valueOf: Source => Value +): + def map[NewValue]( + f: Bin[Source, Value] => Source => NewValue + ): Bin[Source, NewValue] = + copy(valueOf = f(this)) + +case class Bins[T, U](bins: Seq[Bin[T, U]]): + def apply(v: T): Seq[U] = bins.map(_.valueOf(v)) + def map[A](r: Bin[T, U] => T => A): Bins[T, A] = + copy(bins = bins.map(_.map(r))) + + def renderSeparated( + r: Bin[T, U] => T => HtmlElement, + separator: => HtmlElement = span(" / "), + container: HtmlTag[org.scalajs.dom.html.Element] = span + )(v: T): HtmlElement = + container(interleave(map(r)(v), separator)) + + def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala new file mode 100644 index 0000000..a47f5ba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala @@ -0,0 +1,45 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait ButtonComponentsModule: + def buttons: ButtonComponents + + trait ButtonComponents: + def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + +trait DefaultButtonComponentsModule(using ctx: ComponentContext) + extends ButtonComponentsModule: + + override val buttons = new ButtonComponents: + + private inline def srHelp(id: String): Modifier[HtmlElement] = + ctx.messages + .opt(s"form.button.${id}.screenReaderHelp") + .map(sr => span(cls := "sr-only", sr)) + + override def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srHelp(id), + icon + ) + + override def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srHelp(id) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala new file mode 100644 index 0000000..15169f3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala @@ -0,0 +1,191 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom.html.UList +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind + +trait DashboardComponentsModule: + def dashboard: DashboardComponents + + // Only methods that create HtmlElements + trait DashboardComponents(using ComponentContext): + def section(label: String, children: Modifier[HtmlElement]): Div + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li + def cardInitials( + initials: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div + def count(label: String, value: Int, color: ColorDef): Span + def counts(children: HtmlElement*): Span + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + +trait DefaultDashboardComponentsModule(using ComponentContext) + extends DashboardComponentsModule: + override val dashboard: DashboardComponents = new DashboardComponents: + def section(label: String, children: Modifier[HtmlElement]): Div = + div( + cls("mt-3"), + h2( + cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), + label + ), + children + ) + + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = + ul( + cls( + "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + ), + children + ) + + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li = + li( + div( + cls("col-span-1 flex shadow-sm rounded-md"), + initials, + 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"), + div( + cls("text-gray-900 font-medium hover:text-gray-600 truncate"), + label + ), + p(cls("text-gray-500"), children) + ) + ) + ) + ) + + def cardInitials( + id: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div = + div( + cls( + "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" + ), + color.map(_(600).bg), + div(id), + children + ) + + def count(label: String, value: Int, color: ColorDef): Span = + span(color.text, title(label), s"${value}") + + def counts(children: HtmlElement*): Span = + span(interleave(children, span(" / "))) + + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div = + div( + cls("flex flex-col mt-2"), + div( + cls( + "align-middle min-w-full shadow sm:rounded-lg" + ), + L.table( + cls("min-w-full border-separate border-spacing-0"), + styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work + thead( + tr( + cls("border-t border-gray-200"), + headers.map(h => + th( + cls( + "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + h + ) + ) + ) + ), + tbody(cls("bg-white divide-y divide-gray-100"), sections) + ) + ) + ) + + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + td( + cls("bg-gray-50 border-gray-200 border-t border-b"), + cls( + "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + colSpan(cs), + label + ), + mods + ) + + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + mods, + td( + cls( + "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" + ), + label + ), + children.map(e => + td( + cls( + "whitespace-nowrap px-2 py-2 text-sm text-gray-500" + ), + e + ) + ) + ) + + private def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala new file mode 100644 index 0000000..4cff836 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala @@ -0,0 +1,56 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +import zio.* +import com.raquo.airstream.core.Observer +import scala.annotation.implicitNotFound + +trait EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] + +object EffectHandler: + given zioEffectHandler[Env, E, A](using + Runtime[Env] + ): Conversion[ZIOEffectHandler[Env, E, A], EffectHandler[E, A]] with + def apply(h: ZIOEffectHandler[Env, E, A]): EffectHandler[E, A] = + LaminarZIOEffectHandler(h) + + def loggingHandler[E, A](name: String)( + underlying: EffectHandler[E, A] + ): EffectHandler[E, A] = + new EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + underlying( + effects.debugWithName(s"$name effects").debugLog(), + actions.debugWithName(s"$name actions").debugLog() + ) + +class LaminarZIOEffectHandler[Env, E, A](handler: ZIOEffectHandler[Env, E, A])( + using runtime: Runtime[Env] +) extends EffectHandler[E, A]: + + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + onMountCallback(ctx => + effects.foreach { effect => + Unsafe.unsafe { implicit unsafe => + runtime.unsafe + .runToFuture( + handler.handle(effect).either.runForeach { + case Right(a) => ZIO.succeed(actions.onNext(a)) + case Left(e) => ZIO.succeed(actions.onError(e)) + } + ) + } + }(ctx.owner) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala new file mode 100644 index 0000000..df42132 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala @@ -0,0 +1,97 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.raquo.laminar.keys.ReactiveProp +import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent +import org.scalajs.dom.html +import com.raquo.laminar.modifiers.KeyUpdater + +trait FormComponentsModule extends LocalDateSelectModule: + def forms: FormComponents + + trait FormComponents: + def form(mods: Modifier[HtmlElement]*): HtmlElement + + def searchField(id: String, placeholderText: Option[String] = None)( + mods: Modifier[HtmlElement]* + ): HtmlElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement + +trait DefaultFormComponentsModule(using ctx: ComponentContext) + extends FormComponentsModule: + self: IconsModule => + override val forms = new FormComponents: + + override def form( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + override def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label( + forId := id, + cls := "sr-only", + "Hledat" + ), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + icons.`search-solid`() + ), + input( + tpe := "search", + name := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) + + override def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + name(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala new file mode 100644 index 0000000..32fda8a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala @@ -0,0 +1,109 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.CustomAttrs + +trait IconsModule: + def icons: Icons + + trait Icons: + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement + def close(mods: Modifier[SvgElement]*): SvgElement + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement + def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement + +trait DefaultIconsModule(using ComponentContext) extends IconsModule: + override val icons: Icons = new Icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("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" + ) + ) + ) + + override def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala new file mode 100644 index 0000000..f7bacb1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -0,0 +1,35 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +abstract class LaminarComponent[M, A, E]( + effectHandler: EffectHandler[E, A] +) extends Module[M, A, E]: + def render(m: Signal[M], actions: Observer[A]): HtmlElement + + val element: HtmlElement = + val actions = new EventBus[A] + + val zero @ (_, effect) = init + + val initialEffect$ = (effect match + case Some(e) => EventStream.fromValue(e) + case _ => EventStream.empty + ) + + val actions$ = actions.events.recover(handleFailure) + + val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + handle(a, m) + } + + val nextEffects$ = processor$.changes.collect { case (_, Some(e)) => e } + + val model$ = processor$.map(_._1) + + val effect$ = EventStream.merge(initialEffect$, nextEffects$) + + render(model$, actions.writer).amend( + effectHandler(effect$, actions.writer) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala new file mode 100644 index 0000000..98e31b9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala @@ -0,0 +1,114 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html.Paragraph +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.nodes.TextNode + +trait ListComponentsModule: + + def list: ListComponents + + trait ListComponents(using ComponentContext): + def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] + def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None + ): Li + def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] + def listSection(header: String, list: HtmlElement): Div + def navigation(sections: Modifier[HtmlElement]): HtmlElement + +trait DefaultListComponentsModule(using ComponentContext) + extends ListComponentsModule: + override val list: ListComponents = new ListComponents: + override def label( + text: String, + color: ColorKind + ): ReactiveHtmlElement[Paragraph] = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + text + ) + + override def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None + ): Li = + li( + cls("group"), + div( + cls( + "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" + ), + avatar.map(a => + div( + cls("flex-shrink-0"), + div( + cls( + "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" + ), + a + ) + ) + ), + div( + cls("flex-1 min-w-0"), + p( + cls("text-sm font-medium text-gray-900"), + title, + span(cls("float-right"), right) + ), + subtitle.map(st => + p( + cls("text-sm text-gray-500 truncate"), + st + ) + ) + ) + ) + ) + + override def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = + ul( + cls("relative z-0 divide-y divide-gray-200"), + role("list"), + children + ) + + override def listSection( + header: String, + list: HtmlElement + ): Div = + div( + cls("relative"), + div( + 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 + ), + list + ) + + override def navigation(sections: Modifier[HtmlElement]): HtmlElement = + nav( + cls("flex-1 min-h-0 overflow-y-auto"), + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala new file mode 100644 index 0000000..31d860d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -0,0 +1,72 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.raquo.laminar.keys.ReactiveProp +import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent +import org.scalajs.dom.html +import com.raquo.laminar.modifiers.KeyUpdater + +trait LocalDateSelectModule: + val localDateSelect: LocalDateSelect = new LocalDateSelect + + class LocalDateSelect: + import LocalDateSelect.* + + def valueUpdater( + signal: Signal[Option[LocalDate]] + ): KeyUpdater.PropUpdater[String, String] = + L.value <-- signal.map(_.map(formatDate).getOrElse("")) + + // Does not work in `controlled` + // Laminar refuses the custom prop, requries its own `value` or `checked` + val value: ReactiveProp[Option[LocalDate], String] = + customProp("value", OptLocalDateAsStringCodec) + + val min: ReactiveProp[LocalDate, String] = + customProp("min", LocalDateAsStringCodec) + + val max: ReactiveProp[LocalDate, String] = + customProp("max", LocalDateAsStringCodec) + + val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => + d + } + + val onOptInput + : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + onInput.mapToValue.setAsValue.map(parseDate) + + object LocalDateSelect: + import java.time.format.DateTimeFormatter + import java.time.LocalDate + import com.raquo.domtypes.generic.codecs.Codec + + private val formatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd") + + private def parseDate(date: String): Option[LocalDate] = + import scala.util.Try + if date.isEmpty then None + else Try(LocalDate.parse(date, formatter)).toOption + + private def formatDate(date: LocalDate): String = + formatter.format(date) + + object LocalDateAsStringCodec extends Codec[LocalDate, String]: + override def decode(domValue: String): LocalDate = + parseDate(domValue).orNull + + override def encode(scalaValue: LocalDate): String = + formatDate(scalaValue) + + object OptLocalDateAsStringCodec extends Codec[Option[LocalDate], String]: + override def decode(domValue: String): Option[LocalDate] = + parseDate(domValue) + + override def encode(scalaValue: Option[LocalDate]): String = + scalaValue.map(formatDate).getOrElse("") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala new file mode 100644 index 0000000..3f3e7d0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala @@ -0,0 +1,71 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait PageComponentsModule: + + val page: PageComponents + + trait PageComponents: + def container( + children: Modifier[HtmlElement]* + ): HtmlElement + + def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement + + def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement] = emptyMod, + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement + + /** Visage for clickable text, like links + */ + def clickable: Modifier[HtmlElement] + +trait DefaultPageComponentsModule(using ComponentContext) + extends PageComponentsModule: + + override val page: PageComponents = new PageComponents: + override def container( + children: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), + children + ) + + override def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement = + div( + cls("p-8 bg-gray-100 h-full"), + header, + children + ) + + override def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + div( + cls("pb-5 border-b border-gray-200"), + div(cls("float-right"), right), + h1( + cls("text-2xl leading-6 font-medium text-gray-900"), + title + ), + subtitle.map( + p( + cls("text-sm font-medium text-gray-500"), + _ + ) + ) + ) + + override def clickable: Modifier[HtmlElement] = + cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") 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 index 6cda48b..f23f0e8 100644 --- 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 @@ -9,7 +9,7 @@ enum DisplayClass: case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` + `inline-table`, `table-caption` object ShowUpFrom: inline def apply( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/experimental/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/experimental/Color.scala new file mode 100644 index 0000000..45261a7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/experimental/Color.scala @@ -0,0 +1,162 @@ +package works.iterative.ui.components.tailwind.experimental + +import scala.util.NotGiven +import com.raquo.domtypes.generic.Modifier +import org.scalajs.dom.SVGElement + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" + +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") + +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") + +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) + +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..7777c7a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package laminar + +import com.raquo.laminar.api.L.{*, given} + +object LaminarExtensions: + given colorToCSS: Conversion[experimental.Color, Modifier[HtmlElement]] with + def apply(c: experimental.Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[experimental.Color, Modifier[SvgElement]] with + def apply(c: experimental.Color) = svg.cls(c.toCSS) + + given colorSignalToCSS + : Conversion[Signal[experimental.Color], Modifier[HtmlElement]] with + def apply(c: Signal[experimental.Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS + : Conversion[Signal[experimental.Color], Modifier[SvgElement]] with + def apply(c: Signal[experimental.Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala new file mode 100644 index 0000000..6e005af --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala @@ -0,0 +1,78 @@ +package works.iterative.ui +package scenarios + +import com.raquo.laminar.api.L + +import scala.scalajs.js.annotation.{JSExportTopLevel, JSImport} +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom +import org.scalajs.dom.html + +import scala.scalajs.js +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.StyleGuide +import works.iterative.ui.components.tailwind.experimental.Color +import works.iterative.ui.components.tailwind.experimental.ColorWeight + +object Scenario: + type Id = String + +trait Scenario: + trait ScenarioContext: + def events: Observer[Any] + + def id: Scenario.Id + + def label: String + + def element(using ComponentContext): HtmlElement + +trait ScenarioExample: + def title: String + def element(using ComponentContext): HtmlElement + +object ScenarioExample: + def apply( + t: String, + elem: ComponentContext ?=> HtmlElement + ): ScenarioExample = + new ScenarioExample: + override val title: String = t + override def element(using ComponentContext): HtmlElement = elem + +trait ScenarioExamples: + self: Scenario => + + protected def examples(using + ScenarioContext, + ComponentContext + ): List[ScenarioExample] + + override def element(using ComponentContext): HtmlElement = + val eventBus: EventBus[Any] = EventBus[Any]() + + given sc: ScenarioContext = new ScenarioContext: + def events: Observer[Any] = eventBus.writer + + div( + cls("flex flex-col space-y-5"), + eventBus.events --> { e => + org.scalajs.dom.console.log(s"action: ${e.toString}") + }, + examples.map(se => example(se.title, se.element)) + ) + + def example(t: String, c: HtmlElement): Div = + div( + cls("bg-white overflow-hidden shadow rounded-lg"), + div( + cls("px-4 py-5 sm:p-6"), + h3( + cls("text-lg leading-6 font-medium text-gray-900 border-b"), + t + ), + div(cls("px-5 py-5"), c) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala new file mode 100644 index 0000000..90e446c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala @@ -0,0 +1,107 @@ +package works.iterative.ui.scenarios + +import scala.scalajs.js.annotation.{JSExportTopLevel, JSImport} +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom + +import scala.scalajs.js +import works.iterative.ui.JsonMessageCatalogue +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.StyleGuide +import ui.components.tailwind.TailwindSupport +import com.raquo.waypoint.* + +import scala.scalajs.js.Dictionary + +trait ScenarioMain( + prefix: String, + scenarios: List[Scenario], + messages: js.Any, + css: js.Any +) extends TailwindSupport: + + val scenarioMap: Map[Scenario.Id, Scenario] = + scenarios.map(s => (s.id, s)).toMap + + val messageCatalogue: MessageCatalogue = new JsonMessageCatalogue: + override val messages: Dictionary[String] = + ScenarioMain.this.messages.asInstanceOf[js.Dictionary[String]] + + val scenarioRoute: Route[Scenario.Id, String] = + Route.onlyFragment[Scenario.Id, String]( + identity[String], + identity[String], + pattern = root / prefix / "index.html" withFragment fragment[String] + ) + + given router: Router[Scenario.Id] = Router[Scenario.Id]( + routes = List(scenarioRoute), + identity[String], + identity[String], + identity[String], + routeFallback = _ => scenarios.head.id + )( + windowEvents.onPopState, + unsafeWindowOwner + ) + + def main(args: Array[String]): Unit = + given MessageCatalogue = messageCatalogue + + given ComponentContext with + val messages: MessageCatalogue = messageCatalogue + val style: StyleGuide = StyleGuide.default + + def container: HtmlElement = + div( + cls("h-full"), + div( + cls( + "fixed inset-y-0 z-50 flex w-72 flex-col" + ), + div( + cls( + "flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4" + ), + nav( + cls("flex flex-1 flex-col"), + ul( + role("list"), + cls("flex flex-1 flex-col gap-y-7"), + children <-- router.$currentPage.map(id => + scenarios.map(s => + li( + a( + href(s"#${s.id}"), + cls( + "group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold" + ), + if s.id == id then cls("bg-gray-50 text-indigo-600") + else + cls( + "text-gray-700 hover:text-indigo-600 hover:bg-gray-50" + ) + , + s.label + ) + ) + ) + ) + ) + ) + ) + ), + com.raquo.laminar.api.L.main( + cls("h-full pl-72"), + div( + cls( + "h-full max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8" + ), + child <-- router.$currentPage.map(scenarioMap(_).element) + ) + ) + ) + + val appContainer = dom.document.querySelector("#app") + render(appContainer, container) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala new file mode 100644 index 0000000..d2e0b4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag + +case class Bin[Source, +Value]( + label: String, + description: Option[String | HtmlElement], + color: ColorDef, + valueOf: Source => Value +): + def map[NewValue]( + f: Bin[Source, Value] => Source => NewValue + ): Bin[Source, NewValue] = + copy(valueOf = f(this)) + +case class Bins[T, U](bins: Seq[Bin[T, U]]): + def apply(v: T): Seq[U] = bins.map(_.valueOf(v)) + def map[A](r: Bin[T, U] => T => A): Bins[T, A] = + copy(bins = bins.map(_.map(r))) + + def renderSeparated( + r: Bin[T, U] => T => HtmlElement, + separator: => HtmlElement = span(" / "), + container: HtmlTag[org.scalajs.dom.html.Element] = span + )(v: T): HtmlElement = + container(interleave(map(r)(v), separator)) + + def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala new file mode 100644 index 0000000..a47f5ba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala @@ -0,0 +1,45 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait ButtonComponentsModule: + def buttons: ButtonComponents + + trait ButtonComponents: + def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + +trait DefaultButtonComponentsModule(using ctx: ComponentContext) + extends ButtonComponentsModule: + + override val buttons = new ButtonComponents: + + private inline def srHelp(id: String): Modifier[HtmlElement] = + ctx.messages + .opt(s"form.button.${id}.screenReaderHelp") + .map(sr => span(cls := "sr-only", sr)) + + override def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srHelp(id), + icon + ) + + override def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srHelp(id) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala new file mode 100644 index 0000000..15169f3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala @@ -0,0 +1,191 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom.html.UList +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind + +trait DashboardComponentsModule: + def dashboard: DashboardComponents + + // Only methods that create HtmlElements + trait DashboardComponents(using ComponentContext): + def section(label: String, children: Modifier[HtmlElement]): Div + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li + def cardInitials( + initials: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div + def count(label: String, value: Int, color: ColorDef): Span + def counts(children: HtmlElement*): Span + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + +trait DefaultDashboardComponentsModule(using ComponentContext) + extends DashboardComponentsModule: + override val dashboard: DashboardComponents = new DashboardComponents: + def section(label: String, children: Modifier[HtmlElement]): Div = + div( + cls("mt-3"), + h2( + cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), + label + ), + children + ) + + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = + ul( + cls( + "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + ), + children + ) + + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li = + li( + div( + cls("col-span-1 flex shadow-sm rounded-md"), + initials, + 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"), + div( + cls("text-gray-900 font-medium hover:text-gray-600 truncate"), + label + ), + p(cls("text-gray-500"), children) + ) + ) + ) + ) + + def cardInitials( + id: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div = + div( + cls( + "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" + ), + color.map(_(600).bg), + div(id), + children + ) + + def count(label: String, value: Int, color: ColorDef): Span = + span(color.text, title(label), s"${value}") + + def counts(children: HtmlElement*): Span = + span(interleave(children, span(" / "))) + + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div = + div( + cls("flex flex-col mt-2"), + div( + cls( + "align-middle min-w-full shadow sm:rounded-lg" + ), + L.table( + cls("min-w-full border-separate border-spacing-0"), + styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work + thead( + tr( + cls("border-t border-gray-200"), + headers.map(h => + th( + cls( + "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + h + ) + ) + ) + ), + tbody(cls("bg-white divide-y divide-gray-100"), sections) + ) + ) + ) + + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + td( + cls("bg-gray-50 border-gray-200 border-t border-b"), + cls( + "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + colSpan(cs), + label + ), + mods + ) + + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + mods, + td( + cls( + "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" + ), + label + ), + children.map(e => + td( + cls( + "whitespace-nowrap px-2 py-2 text-sm text-gray-500" + ), + e + ) + ) + ) + + private def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala new file mode 100644 index 0000000..4cff836 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala @@ -0,0 +1,56 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +import zio.* +import com.raquo.airstream.core.Observer +import scala.annotation.implicitNotFound + +trait EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] + +object EffectHandler: + given zioEffectHandler[Env, E, A](using + Runtime[Env] + ): Conversion[ZIOEffectHandler[Env, E, A], EffectHandler[E, A]] with + def apply(h: ZIOEffectHandler[Env, E, A]): EffectHandler[E, A] = + LaminarZIOEffectHandler(h) + + def loggingHandler[E, A](name: String)( + underlying: EffectHandler[E, A] + ): EffectHandler[E, A] = + new EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + underlying( + effects.debugWithName(s"$name effects").debugLog(), + actions.debugWithName(s"$name actions").debugLog() + ) + +class LaminarZIOEffectHandler[Env, E, A](handler: ZIOEffectHandler[Env, E, A])( + using runtime: Runtime[Env] +) extends EffectHandler[E, A]: + + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + onMountCallback(ctx => + effects.foreach { effect => + Unsafe.unsafe { implicit unsafe => + runtime.unsafe + .runToFuture( + handler.handle(effect).either.runForeach { + case Right(a) => ZIO.succeed(actions.onNext(a)) + case Left(e) => ZIO.succeed(actions.onError(e)) + } + ) + } + }(ctx.owner) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala new file mode 100644 index 0000000..df42132 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala @@ -0,0 +1,97 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.raquo.laminar.keys.ReactiveProp +import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent +import org.scalajs.dom.html +import com.raquo.laminar.modifiers.KeyUpdater + +trait FormComponentsModule extends LocalDateSelectModule: + def forms: FormComponents + + trait FormComponents: + def form(mods: Modifier[HtmlElement]*): HtmlElement + + def searchField(id: String, placeholderText: Option[String] = None)( + mods: Modifier[HtmlElement]* + ): HtmlElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement + +trait DefaultFormComponentsModule(using ctx: ComponentContext) + extends FormComponentsModule: + self: IconsModule => + override val forms = new FormComponents: + + override def form( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + override def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label( + forId := id, + cls := "sr-only", + "Hledat" + ), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + icons.`search-solid`() + ), + input( + tpe := "search", + name := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) + + override def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + name(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala new file mode 100644 index 0000000..32fda8a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala @@ -0,0 +1,109 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.CustomAttrs + +trait IconsModule: + def icons: Icons + + trait Icons: + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement + def close(mods: Modifier[SvgElement]*): SvgElement + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement + def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement + +trait DefaultIconsModule(using ComponentContext) extends IconsModule: + override val icons: Icons = new Icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("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" + ) + ) + ) + + override def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala new file mode 100644 index 0000000..f7bacb1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -0,0 +1,35 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +abstract class LaminarComponent[M, A, E]( + effectHandler: EffectHandler[E, A] +) extends Module[M, A, E]: + def render(m: Signal[M], actions: Observer[A]): HtmlElement + + val element: HtmlElement = + val actions = new EventBus[A] + + val zero @ (_, effect) = init + + val initialEffect$ = (effect match + case Some(e) => EventStream.fromValue(e) + case _ => EventStream.empty + ) + + val actions$ = actions.events.recover(handleFailure) + + val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + handle(a, m) + } + + val nextEffects$ = processor$.changes.collect { case (_, Some(e)) => e } + + val model$ = processor$.map(_._1) + + val effect$ = EventStream.merge(initialEffect$, nextEffects$) + + render(model$, actions.writer).amend( + effectHandler(effect$, actions.writer) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala new file mode 100644 index 0000000..98e31b9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala @@ -0,0 +1,114 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html.Paragraph +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.nodes.TextNode + +trait ListComponentsModule: + + def list: ListComponents + + trait ListComponents(using ComponentContext): + def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] + def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None + ): Li + def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] + def listSection(header: String, list: HtmlElement): Div + def navigation(sections: Modifier[HtmlElement]): HtmlElement + +trait DefaultListComponentsModule(using ComponentContext) + extends ListComponentsModule: + override val list: ListComponents = new ListComponents: + override def label( + text: String, + color: ColorKind + ): ReactiveHtmlElement[Paragraph] = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + text + ) + + override def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None + ): Li = + li( + cls("group"), + div( + cls( + "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" + ), + avatar.map(a => + div( + cls("flex-shrink-0"), + div( + cls( + "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" + ), + a + ) + ) + ), + div( + cls("flex-1 min-w-0"), + p( + cls("text-sm font-medium text-gray-900"), + title, + span(cls("float-right"), right) + ), + subtitle.map(st => + p( + cls("text-sm text-gray-500 truncate"), + st + ) + ) + ) + ) + ) + + override def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = + ul( + cls("relative z-0 divide-y divide-gray-200"), + role("list"), + children + ) + + override def listSection( + header: String, + list: HtmlElement + ): Div = + div( + cls("relative"), + div( + 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 + ), + list + ) + + override def navigation(sections: Modifier[HtmlElement]): HtmlElement = + nav( + cls("flex-1 min-h-0 overflow-y-auto"), + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala new file mode 100644 index 0000000..31d860d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -0,0 +1,72 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.raquo.laminar.keys.ReactiveProp +import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent +import org.scalajs.dom.html +import com.raquo.laminar.modifiers.KeyUpdater + +trait LocalDateSelectModule: + val localDateSelect: LocalDateSelect = new LocalDateSelect + + class LocalDateSelect: + import LocalDateSelect.* + + def valueUpdater( + signal: Signal[Option[LocalDate]] + ): KeyUpdater.PropUpdater[String, String] = + L.value <-- signal.map(_.map(formatDate).getOrElse("")) + + // Does not work in `controlled` + // Laminar refuses the custom prop, requries its own `value` or `checked` + val value: ReactiveProp[Option[LocalDate], String] = + customProp("value", OptLocalDateAsStringCodec) + + val min: ReactiveProp[LocalDate, String] = + customProp("min", LocalDateAsStringCodec) + + val max: ReactiveProp[LocalDate, String] = + customProp("max", LocalDateAsStringCodec) + + val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => + d + } + + val onOptInput + : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + onInput.mapToValue.setAsValue.map(parseDate) + + object LocalDateSelect: + import java.time.format.DateTimeFormatter + import java.time.LocalDate + import com.raquo.domtypes.generic.codecs.Codec + + private val formatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd") + + private def parseDate(date: String): Option[LocalDate] = + import scala.util.Try + if date.isEmpty then None + else Try(LocalDate.parse(date, formatter)).toOption + + private def formatDate(date: LocalDate): String = + formatter.format(date) + + object LocalDateAsStringCodec extends Codec[LocalDate, String]: + override def decode(domValue: String): LocalDate = + parseDate(domValue).orNull + + override def encode(scalaValue: LocalDate): String = + formatDate(scalaValue) + + object OptLocalDateAsStringCodec extends Codec[Option[LocalDate], String]: + override def decode(domValue: String): Option[LocalDate] = + parseDate(domValue) + + override def encode(scalaValue: Option[LocalDate]): String = + scalaValue.map(formatDate).getOrElse("") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala new file mode 100644 index 0000000..3f3e7d0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala @@ -0,0 +1,71 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait PageComponentsModule: + + val page: PageComponents + + trait PageComponents: + def container( + children: Modifier[HtmlElement]* + ): HtmlElement + + def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement + + def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement] = emptyMod, + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement + + /** Visage for clickable text, like links + */ + def clickable: Modifier[HtmlElement] + +trait DefaultPageComponentsModule(using ComponentContext) + extends PageComponentsModule: + + override val page: PageComponents = new PageComponents: + override def container( + children: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), + children + ) + + override def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement = + div( + cls("p-8 bg-gray-100 h-full"), + header, + children + ) + + override def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + div( + cls("pb-5 border-b border-gray-200"), + div(cls("float-right"), right), + h1( + cls("text-2xl leading-6 font-medium text-gray-900"), + title + ), + subtitle.map( + p( + cls("text-sm font-medium text-gray-500"), + _ + ) + ) + ) + + override def clickable: Modifier[HtmlElement] = + cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") 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 index 6cda48b..f23f0e8 100644 --- 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 @@ -9,7 +9,7 @@ enum DisplayClass: case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` + `inline-table`, `table-caption` object ShowUpFrom: inline def apply( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/experimental/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/experimental/Color.scala new file mode 100644 index 0000000..45261a7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/experimental/Color.scala @@ -0,0 +1,162 @@ +package works.iterative.ui.components.tailwind.experimental + +import scala.util.NotGiven +import com.raquo.domtypes.generic.Modifier +import org.scalajs.dom.SVGElement + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" + +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") + +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") + +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) + +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..7777c7a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package laminar + +import com.raquo.laminar.api.L.{*, given} + +object LaminarExtensions: + given colorToCSS: Conversion[experimental.Color, Modifier[HtmlElement]] with + def apply(c: experimental.Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[experimental.Color, Modifier[SvgElement]] with + def apply(c: experimental.Color) = svg.cls(c.toCSS) + + given colorSignalToCSS + : Conversion[Signal[experimental.Color], Modifier[HtmlElement]] with + def apply(c: Signal[experimental.Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS + : Conversion[Signal[experimental.Color], Modifier[SvgElement]] with + def apply(c: Signal[experimental.Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala new file mode 100644 index 0000000..6e005af --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala @@ -0,0 +1,78 @@ +package works.iterative.ui +package scenarios + +import com.raquo.laminar.api.L + +import scala.scalajs.js.annotation.{JSExportTopLevel, JSImport} +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom +import org.scalajs.dom.html + +import scala.scalajs.js +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.StyleGuide +import works.iterative.ui.components.tailwind.experimental.Color +import works.iterative.ui.components.tailwind.experimental.ColorWeight + +object Scenario: + type Id = String + +trait Scenario: + trait ScenarioContext: + def events: Observer[Any] + + def id: Scenario.Id + + def label: String + + def element(using ComponentContext): HtmlElement + +trait ScenarioExample: + def title: String + def element(using ComponentContext): HtmlElement + +object ScenarioExample: + def apply( + t: String, + elem: ComponentContext ?=> HtmlElement + ): ScenarioExample = + new ScenarioExample: + override val title: String = t + override def element(using ComponentContext): HtmlElement = elem + +trait ScenarioExamples: + self: Scenario => + + protected def examples(using + ScenarioContext, + ComponentContext + ): List[ScenarioExample] + + override def element(using ComponentContext): HtmlElement = + val eventBus: EventBus[Any] = EventBus[Any]() + + given sc: ScenarioContext = new ScenarioContext: + def events: Observer[Any] = eventBus.writer + + div( + cls("flex flex-col space-y-5"), + eventBus.events --> { e => + org.scalajs.dom.console.log(s"action: ${e.toString}") + }, + examples.map(se => example(se.title, se.element)) + ) + + def example(t: String, c: HtmlElement): Div = + div( + cls("bg-white overflow-hidden shadow rounded-lg"), + div( + cls("px-4 py-5 sm:p-6"), + h3( + cls("text-lg leading-6 font-medium text-gray-900 border-b"), + t + ), + div(cls("px-5 py-5"), c) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala new file mode 100644 index 0000000..90e446c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala @@ -0,0 +1,107 @@ +package works.iterative.ui.scenarios + +import scala.scalajs.js.annotation.{JSExportTopLevel, JSImport} +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom + +import scala.scalajs.js +import works.iterative.ui.JsonMessageCatalogue +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.StyleGuide +import ui.components.tailwind.TailwindSupport +import com.raquo.waypoint.* + +import scala.scalajs.js.Dictionary + +trait ScenarioMain( + prefix: String, + scenarios: List[Scenario], + messages: js.Any, + css: js.Any +) extends TailwindSupport: + + val scenarioMap: Map[Scenario.Id, Scenario] = + scenarios.map(s => (s.id, s)).toMap + + val messageCatalogue: MessageCatalogue = new JsonMessageCatalogue: + override val messages: Dictionary[String] = + ScenarioMain.this.messages.asInstanceOf[js.Dictionary[String]] + + val scenarioRoute: Route[Scenario.Id, String] = + Route.onlyFragment[Scenario.Id, String]( + identity[String], + identity[String], + pattern = root / prefix / "index.html" withFragment fragment[String] + ) + + given router: Router[Scenario.Id] = Router[Scenario.Id]( + routes = List(scenarioRoute), + identity[String], + identity[String], + identity[String], + routeFallback = _ => scenarios.head.id + )( + windowEvents.onPopState, + unsafeWindowOwner + ) + + def main(args: Array[String]): Unit = + given MessageCatalogue = messageCatalogue + + given ComponentContext with + val messages: MessageCatalogue = messageCatalogue + val style: StyleGuide = StyleGuide.default + + def container: HtmlElement = + div( + cls("h-full"), + div( + cls( + "fixed inset-y-0 z-50 flex w-72 flex-col" + ), + div( + cls( + "flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4" + ), + nav( + cls("flex flex-1 flex-col"), + ul( + role("list"), + cls("flex flex-1 flex-col gap-y-7"), + children <-- router.$currentPage.map(id => + scenarios.map(s => + li( + a( + href(s"#${s.id}"), + cls( + "group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold" + ), + if s.id == id then cls("bg-gray-50 text-indigo-600") + else + cls( + "text-gray-700 hover:text-indigo-600 hover:bg-gray-50" + ) + , + s.label + ) + ) + ) + ) + ) + ) + ) + ), + com.raquo.laminar.api.L.main( + cls("h-full pl-72"), + div( + cls( + "h-full max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8" + ), + child <-- router.$currentPage.map(scenarioMap(_).element) + ) + ) + ) + + val appContainer = dom.document.querySelector("#app") + render(appContainer, container) diff --git a/ui/jvm/src/main/scala/works/iterative/data/table/XSSFWriter.scala b/ui/jvm/src/main/scala/works/iterative/data/table/XSSFWriter.scala new file mode 100644 index 0000000..912e74e --- /dev/null +++ b/ui/jvm/src/main/scala/works/iterative/data/table/XSSFWriter.scala @@ -0,0 +1,195 @@ +package works.iterative.data.table + +import zio.* +import org.apache.poi.xssf.usermodel.XSSFRow +import org.apache.poi.ss.usermodel.CellStyle +import java.time.LocalDate +import org.apache.poi.xssf.usermodel.XSSFSheet +import org.apache.poi.xssf.usermodel.XSSFWorkbook +import org.apache.poi.xssf.usermodel.XSSFCellStyle +import org.apache.poi.ss.usermodel.IndexedColors +import org.apache.poi.ss.usermodel.FillPatternType +import org.apache.poi.ss.usermodel.BorderStyle +import org.apache.poi.xssf.usermodel.XSSFCell +import org.apache.poi.ss.usermodel.Hyperlink +import org.apache.poi.common.usermodel.HyperlinkType + +case class ExternalLink( + name: String, + href: String +) + +trait CellWriter[T]: + def write(cell: XSSFCell, value: T): Unit + +object CellWriter: + given CellWriter[String] with + def write(cell: XSSFCell, value: String) = cell.setCellValue(value) + + given CellWriter[Int] with + def write(cell: XSSFCell, value: Int) = cell.setCellValue(value) + + given CellWriter[LocalDate] with + def write(cell: XSSFCell, value: LocalDate) = cell.setCellValue(value) + + given CellWriter[ExternalLink] with + def write(cell: XSSFCell, value: ExternalLink) = + cell.setCellValue(value.name) + cell.setHyperlink( + cell.getSheet.getWorkbook.getCreationHelper.createHyperlink( + HyperlinkType.URL + ) + ) + cell.getHyperlink.setAddress(value.href) + + given optionalCellSetter[T](using CellWriter[T]): CellWriter[Option[T]] with + def write(cell: XSSFCell, value: Option[T]) = + value.foreach(summon[CellWriter[T]].write(cell, _)) + +case class ColumnSpec[T, U: CellWriter]( + id: String, + name: String, + get: T => U, + width: Int = 20 +): + def set(cell: XSSFCell, row: T) = + summon[CellWriter[U]].write(cell, get(row)) + +trait TableRowWriter[T]: + def writeHeaderCells(row: XSSFRow)(using TableCellStyles): UIO[Unit] + def writeDataCells(row: XSSFRow, value: T)(using TableCellStyles): UIO[Unit] + def columnSizes: List[Int] + +class ColumnSpecsTableRowWriter[T](specs: List[ColumnSpec[T, _]]) + extends TableRowWriter[T]: + override def writeHeaderCells(row: XSSFRow)(using styles: TableCellStyles) = + val headers = specs.map(_.name) + ZIO.succeed { + headers.zipWithIndex.foreach { case (header, index) => + val cell = row.createCell(index) + cell.setCellValue(header) + styles + .byName(TableCellStyles.HeaderStyleName) + .foreach(cell.setCellStyle) + } + } + + override def writeDataCells( + row: XSSFRow, + value: T + )(using styles: TableCellStyles) = + ZIO.succeed { + specs.zipWithIndex.map { case (spec, i) => + val cell = row.createCell(i) + spec.set(cell, value) + styles.byName(spec.id).foreach(cell.setCellStyle) + } + } + + override def columnSizes: List[Int] = + specs.map(_.width).toList + +type TableCellStyleDef = XSSFWorkbook => XSSFCellStyle + +trait TableCellStyles: + def byName(name: String): Option[CellStyle] + +object TableCellStyles: + val HeaderStyleName = "_header" + + val defaultHeaderStyle: TableCellStyleDef = (wb: XSSFWorkbook) => { + val headerStyle = wb.createCellStyle() + val font = wb.createFont() + font.setBold(true) + headerStyle.setFont(font) + + // set background color + headerStyle.setFillForegroundColor( + IndexedColors.GREY_25_PERCENT.getIndex() + ) + headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND) + + // set border style + headerStyle.setBorderTop(BorderStyle.MEDIUM) + headerStyle.setBorderBottom(BorderStyle.MEDIUM) + headerStyle.setBorderLeft(BorderStyle.MEDIUM) + headerStyle.setBorderRight(BorderStyle.MEDIUM) + headerStyle + } + + val dateStyle: TableCellStyleDef = (wb: XSSFWorkbook) => { + val dateCellStyle = wb.createCellStyle() + dateCellStyle.setDataFormat( + wb + .getCreationHelper() + .createDataFormat() + .getFormat("dd.MM.yyyy") + ) + dateCellStyle + } + + val wrapStyle: TableCellStyleDef = (wb: XSSFWorkbook) => { + // set cell style to shrink font size and truncate text + val wrapCellStyle = wb.createCellStyle() + wrapCellStyle.setWrapText(true) + wrapCellStyle + } + + def make(): UIO[TableCellStyles] = + ZIO.succeed( + new TableCellStyles: + override def byName(name: String): Option[CellStyle] = + None + ) + + def make( + wb: XSSFWorkbook, + styles: List[(String, TableCellStyleDef)] + ): UIO[TableCellStyles] = + ZIO + .collectAll(styles.map { case (name, styleDef) => + for style <- ZIO.succeed(styleDef(wb)) + yield name -> style + }) + .map { styles => + new TableCellStyles: + override def byName(name: String): Option[CellStyle] = + styles.toMap.get(name) + } + +class TableWorkbook private (workbook: XSSFWorkbook, styles: TableCellStyles): + def writeSheet[T](name: String, data: Iterable[T])(using + writer: TableRowWriter[T] + ): UIO[Unit] = + for + sheet <- ZIO.succeed(workbook.createSheet(name)) + headerRow <- ZIO.succeed(sheet.createRow(0)) + _ <- writer.writeHeaderCells(headerRow)(using styles) + _ <- ZIO.foreach(data.zipWithIndex) { case (value, index) => + val row = sheet.createRow(index + 1) + writer.writeDataCells(row, value)(using styles) + } + _ <- ZIO.foreach(writer.columnSizes.zipWithIndex) { case (size, index) => + ZIO.succeed(sheet.setColumnWidth(index, size * 256)) + } + yield () + + def toArray: UIO[Array[Byte]] = ZIO.succeed { + val out = new java.io.ByteArrayOutputStream() + workbook.write(out) + out.close() + out.toByteArray + } + +object TableWorkbook: + def make(): UIO[TableWorkbook] = + for + wb <- ZIO.succeed(XSSFWorkbook()) + styles <- TableCellStyles.make() + yield TableWorkbook(wb, styles) + + def make(styles: List[(String, TableCellStyleDef)]): UIO[TableWorkbook] = + for + wb <- ZIO.succeed(XSSFWorkbook()) + styles <- TableCellStyles.make(wb, styles) + yield TableWorkbook(wb, styles) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala new file mode 100644 index 0000000..d2e0b4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag + +case class Bin[Source, +Value]( + label: String, + description: Option[String | HtmlElement], + color: ColorDef, + valueOf: Source => Value +): + def map[NewValue]( + f: Bin[Source, Value] => Source => NewValue + ): Bin[Source, NewValue] = + copy(valueOf = f(this)) + +case class Bins[T, U](bins: Seq[Bin[T, U]]): + def apply(v: T): Seq[U] = bins.map(_.valueOf(v)) + def map[A](r: Bin[T, U] => T => A): Bins[T, A] = + copy(bins = bins.map(_.map(r))) + + def renderSeparated( + r: Bin[T, U] => T => HtmlElement, + separator: => HtmlElement = span(" / "), + container: HtmlTag[org.scalajs.dom.html.Element] = span + )(v: T): HtmlElement = + container(interleave(map(r)(v), separator)) + + def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala new file mode 100644 index 0000000..a47f5ba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala @@ -0,0 +1,45 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait ButtonComponentsModule: + def buttons: ButtonComponents + + trait ButtonComponents: + def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + +trait DefaultButtonComponentsModule(using ctx: ComponentContext) + extends ButtonComponentsModule: + + override val buttons = new ButtonComponents: + + private inline def srHelp(id: String): Modifier[HtmlElement] = + ctx.messages + .opt(s"form.button.${id}.screenReaderHelp") + .map(sr => span(cls := "sr-only", sr)) + + override def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srHelp(id), + icon + ) + + override def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srHelp(id) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala new file mode 100644 index 0000000..15169f3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala @@ -0,0 +1,191 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom.html.UList +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind + +trait DashboardComponentsModule: + def dashboard: DashboardComponents + + // Only methods that create HtmlElements + trait DashboardComponents(using ComponentContext): + def section(label: String, children: Modifier[HtmlElement]): Div + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li + def cardInitials( + initials: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div + def count(label: String, value: Int, color: ColorDef): Span + def counts(children: HtmlElement*): Span + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + +trait DefaultDashboardComponentsModule(using ComponentContext) + extends DashboardComponentsModule: + override val dashboard: DashboardComponents = new DashboardComponents: + def section(label: String, children: Modifier[HtmlElement]): Div = + div( + cls("mt-3"), + h2( + cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), + label + ), + children + ) + + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = + ul( + cls( + "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + ), + children + ) + + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li = + li( + div( + cls("col-span-1 flex shadow-sm rounded-md"), + initials, + 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"), + div( + cls("text-gray-900 font-medium hover:text-gray-600 truncate"), + label + ), + p(cls("text-gray-500"), children) + ) + ) + ) + ) + + def cardInitials( + id: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div = + div( + cls( + "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" + ), + color.map(_(600).bg), + div(id), + children + ) + + def count(label: String, value: Int, color: ColorDef): Span = + span(color.text, title(label), s"${value}") + + def counts(children: HtmlElement*): Span = + span(interleave(children, span(" / "))) + + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div = + div( + cls("flex flex-col mt-2"), + div( + cls( + "align-middle min-w-full shadow sm:rounded-lg" + ), + L.table( + cls("min-w-full border-separate border-spacing-0"), + styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work + thead( + tr( + cls("border-t border-gray-200"), + headers.map(h => + th( + cls( + "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + h + ) + ) + ) + ), + tbody(cls("bg-white divide-y divide-gray-100"), sections) + ) + ) + ) + + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + td( + cls("bg-gray-50 border-gray-200 border-t border-b"), + cls( + "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + colSpan(cs), + label + ), + mods + ) + + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + mods, + td( + cls( + "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" + ), + label + ), + children.map(e => + td( + cls( + "whitespace-nowrap px-2 py-2 text-sm text-gray-500" + ), + e + ) + ) + ) + + private def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala new file mode 100644 index 0000000..4cff836 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala @@ -0,0 +1,56 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +import zio.* +import com.raquo.airstream.core.Observer +import scala.annotation.implicitNotFound + +trait EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] + +object EffectHandler: + given zioEffectHandler[Env, E, A](using + Runtime[Env] + ): Conversion[ZIOEffectHandler[Env, E, A], EffectHandler[E, A]] with + def apply(h: ZIOEffectHandler[Env, E, A]): EffectHandler[E, A] = + LaminarZIOEffectHandler(h) + + def loggingHandler[E, A](name: String)( + underlying: EffectHandler[E, A] + ): EffectHandler[E, A] = + new EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + underlying( + effects.debugWithName(s"$name effects").debugLog(), + actions.debugWithName(s"$name actions").debugLog() + ) + +class LaminarZIOEffectHandler[Env, E, A](handler: ZIOEffectHandler[Env, E, A])( + using runtime: Runtime[Env] +) extends EffectHandler[E, A]: + + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + onMountCallback(ctx => + effects.foreach { effect => + Unsafe.unsafe { implicit unsafe => + runtime.unsafe + .runToFuture( + handler.handle(effect).either.runForeach { + case Right(a) => ZIO.succeed(actions.onNext(a)) + case Left(e) => ZIO.succeed(actions.onError(e)) + } + ) + } + }(ctx.owner) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala new file mode 100644 index 0000000..df42132 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala @@ -0,0 +1,97 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.raquo.laminar.keys.ReactiveProp +import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent +import org.scalajs.dom.html +import com.raquo.laminar.modifiers.KeyUpdater + +trait FormComponentsModule extends LocalDateSelectModule: + def forms: FormComponents + + trait FormComponents: + def form(mods: Modifier[HtmlElement]*): HtmlElement + + def searchField(id: String, placeholderText: Option[String] = None)( + mods: Modifier[HtmlElement]* + ): HtmlElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement + +trait DefaultFormComponentsModule(using ctx: ComponentContext) + extends FormComponentsModule: + self: IconsModule => + override val forms = new FormComponents: + + override def form( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + override def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label( + forId := id, + cls := "sr-only", + "Hledat" + ), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + icons.`search-solid`() + ), + input( + tpe := "search", + name := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) + + override def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + name(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala new file mode 100644 index 0000000..32fda8a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala @@ -0,0 +1,109 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.CustomAttrs + +trait IconsModule: + def icons: Icons + + trait Icons: + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement + def close(mods: Modifier[SvgElement]*): SvgElement + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement + def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement + +trait DefaultIconsModule(using ComponentContext) extends IconsModule: + override val icons: Icons = new Icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("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" + ) + ) + ) + + override def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala new file mode 100644 index 0000000..f7bacb1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -0,0 +1,35 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +abstract class LaminarComponent[M, A, E]( + effectHandler: EffectHandler[E, A] +) extends Module[M, A, E]: + def render(m: Signal[M], actions: Observer[A]): HtmlElement + + val element: HtmlElement = + val actions = new EventBus[A] + + val zero @ (_, effect) = init + + val initialEffect$ = (effect match + case Some(e) => EventStream.fromValue(e) + case _ => EventStream.empty + ) + + val actions$ = actions.events.recover(handleFailure) + + val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + handle(a, m) + } + + val nextEffects$ = processor$.changes.collect { case (_, Some(e)) => e } + + val model$ = processor$.map(_._1) + + val effect$ = EventStream.merge(initialEffect$, nextEffects$) + + render(model$, actions.writer).amend( + effectHandler(effect$, actions.writer) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala new file mode 100644 index 0000000..98e31b9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala @@ -0,0 +1,114 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html.Paragraph +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.nodes.TextNode + +trait ListComponentsModule: + + def list: ListComponents + + trait ListComponents(using ComponentContext): + def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] + def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None + ): Li + def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] + def listSection(header: String, list: HtmlElement): Div + def navigation(sections: Modifier[HtmlElement]): HtmlElement + +trait DefaultListComponentsModule(using ComponentContext) + extends ListComponentsModule: + override val list: ListComponents = new ListComponents: + override def label( + text: String, + color: ColorKind + ): ReactiveHtmlElement[Paragraph] = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + text + ) + + override def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None + ): Li = + li( + cls("group"), + div( + cls( + "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" + ), + avatar.map(a => + div( + cls("flex-shrink-0"), + div( + cls( + "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" + ), + a + ) + ) + ), + div( + cls("flex-1 min-w-0"), + p( + cls("text-sm font-medium text-gray-900"), + title, + span(cls("float-right"), right) + ), + subtitle.map(st => + p( + cls("text-sm text-gray-500 truncate"), + st + ) + ) + ) + ) + ) + + override def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = + ul( + cls("relative z-0 divide-y divide-gray-200"), + role("list"), + children + ) + + override def listSection( + header: String, + list: HtmlElement + ): Div = + div( + cls("relative"), + div( + 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 + ), + list + ) + + override def navigation(sections: Modifier[HtmlElement]): HtmlElement = + nav( + cls("flex-1 min-h-0 overflow-y-auto"), + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala new file mode 100644 index 0000000..31d860d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -0,0 +1,72 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.raquo.laminar.keys.ReactiveProp +import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent +import org.scalajs.dom.html +import com.raquo.laminar.modifiers.KeyUpdater + +trait LocalDateSelectModule: + val localDateSelect: LocalDateSelect = new LocalDateSelect + + class LocalDateSelect: + import LocalDateSelect.* + + def valueUpdater( + signal: Signal[Option[LocalDate]] + ): KeyUpdater.PropUpdater[String, String] = + L.value <-- signal.map(_.map(formatDate).getOrElse("")) + + // Does not work in `controlled` + // Laminar refuses the custom prop, requries its own `value` or `checked` + val value: ReactiveProp[Option[LocalDate], String] = + customProp("value", OptLocalDateAsStringCodec) + + val min: ReactiveProp[LocalDate, String] = + customProp("min", LocalDateAsStringCodec) + + val max: ReactiveProp[LocalDate, String] = + customProp("max", LocalDateAsStringCodec) + + val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => + d + } + + val onOptInput + : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + onInput.mapToValue.setAsValue.map(parseDate) + + object LocalDateSelect: + import java.time.format.DateTimeFormatter + import java.time.LocalDate + import com.raquo.domtypes.generic.codecs.Codec + + private val formatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd") + + private def parseDate(date: String): Option[LocalDate] = + import scala.util.Try + if date.isEmpty then None + else Try(LocalDate.parse(date, formatter)).toOption + + private def formatDate(date: LocalDate): String = + formatter.format(date) + + object LocalDateAsStringCodec extends Codec[LocalDate, String]: + override def decode(domValue: String): LocalDate = + parseDate(domValue).orNull + + override def encode(scalaValue: LocalDate): String = + formatDate(scalaValue) + + object OptLocalDateAsStringCodec extends Codec[Option[LocalDate], String]: + override def decode(domValue: String): Option[LocalDate] = + parseDate(domValue) + + override def encode(scalaValue: Option[LocalDate]): String = + scalaValue.map(formatDate).getOrElse("") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala new file mode 100644 index 0000000..3f3e7d0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala @@ -0,0 +1,71 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait PageComponentsModule: + + val page: PageComponents + + trait PageComponents: + def container( + children: Modifier[HtmlElement]* + ): HtmlElement + + def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement + + def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement] = emptyMod, + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement + + /** Visage for clickable text, like links + */ + def clickable: Modifier[HtmlElement] + +trait DefaultPageComponentsModule(using ComponentContext) + extends PageComponentsModule: + + override val page: PageComponents = new PageComponents: + override def container( + children: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), + children + ) + + override def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement = + div( + cls("p-8 bg-gray-100 h-full"), + header, + children + ) + + override def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + div( + cls("pb-5 border-b border-gray-200"), + div(cls("float-right"), right), + h1( + cls("text-2xl leading-6 font-medium text-gray-900"), + title + ), + subtitle.map( + p( + cls("text-sm font-medium text-gray-500"), + _ + ) + ) + ) + + override def clickable: Modifier[HtmlElement] = + cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") 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 index 6cda48b..f23f0e8 100644 --- 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 @@ -9,7 +9,7 @@ enum DisplayClass: case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` + `inline-table`, `table-caption` object ShowUpFrom: inline def apply( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/experimental/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/experimental/Color.scala new file mode 100644 index 0000000..45261a7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/experimental/Color.scala @@ -0,0 +1,162 @@ +package works.iterative.ui.components.tailwind.experimental + +import scala.util.NotGiven +import com.raquo.domtypes.generic.Modifier +import org.scalajs.dom.SVGElement + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" + +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") + +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") + +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) + +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..7777c7a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package laminar + +import com.raquo.laminar.api.L.{*, given} + +object LaminarExtensions: + given colorToCSS: Conversion[experimental.Color, Modifier[HtmlElement]] with + def apply(c: experimental.Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[experimental.Color, Modifier[SvgElement]] with + def apply(c: experimental.Color) = svg.cls(c.toCSS) + + given colorSignalToCSS + : Conversion[Signal[experimental.Color], Modifier[HtmlElement]] with + def apply(c: Signal[experimental.Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS + : Conversion[Signal[experimental.Color], Modifier[SvgElement]] with + def apply(c: Signal[experimental.Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala new file mode 100644 index 0000000..6e005af --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala @@ -0,0 +1,78 @@ +package works.iterative.ui +package scenarios + +import com.raquo.laminar.api.L + +import scala.scalajs.js.annotation.{JSExportTopLevel, JSImport} +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom +import org.scalajs.dom.html + +import scala.scalajs.js +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.StyleGuide +import works.iterative.ui.components.tailwind.experimental.Color +import works.iterative.ui.components.tailwind.experimental.ColorWeight + +object Scenario: + type Id = String + +trait Scenario: + trait ScenarioContext: + def events: Observer[Any] + + def id: Scenario.Id + + def label: String + + def element(using ComponentContext): HtmlElement + +trait ScenarioExample: + def title: String + def element(using ComponentContext): HtmlElement + +object ScenarioExample: + def apply( + t: String, + elem: ComponentContext ?=> HtmlElement + ): ScenarioExample = + new ScenarioExample: + override val title: String = t + override def element(using ComponentContext): HtmlElement = elem + +trait ScenarioExamples: + self: Scenario => + + protected def examples(using + ScenarioContext, + ComponentContext + ): List[ScenarioExample] + + override def element(using ComponentContext): HtmlElement = + val eventBus: EventBus[Any] = EventBus[Any]() + + given sc: ScenarioContext = new ScenarioContext: + def events: Observer[Any] = eventBus.writer + + div( + cls("flex flex-col space-y-5"), + eventBus.events --> { e => + org.scalajs.dom.console.log(s"action: ${e.toString}") + }, + examples.map(se => example(se.title, se.element)) + ) + + def example(t: String, c: HtmlElement): Div = + div( + cls("bg-white overflow-hidden shadow rounded-lg"), + div( + cls("px-4 py-5 sm:p-6"), + h3( + cls("text-lg leading-6 font-medium text-gray-900 border-b"), + t + ), + div(cls("px-5 py-5"), c) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala new file mode 100644 index 0000000..90e446c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala @@ -0,0 +1,107 @@ +package works.iterative.ui.scenarios + +import scala.scalajs.js.annotation.{JSExportTopLevel, JSImport} +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom + +import scala.scalajs.js +import works.iterative.ui.JsonMessageCatalogue +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.StyleGuide +import ui.components.tailwind.TailwindSupport +import com.raquo.waypoint.* + +import scala.scalajs.js.Dictionary + +trait ScenarioMain( + prefix: String, + scenarios: List[Scenario], + messages: js.Any, + css: js.Any +) extends TailwindSupport: + + val scenarioMap: Map[Scenario.Id, Scenario] = + scenarios.map(s => (s.id, s)).toMap + + val messageCatalogue: MessageCatalogue = new JsonMessageCatalogue: + override val messages: Dictionary[String] = + ScenarioMain.this.messages.asInstanceOf[js.Dictionary[String]] + + val scenarioRoute: Route[Scenario.Id, String] = + Route.onlyFragment[Scenario.Id, String]( + identity[String], + identity[String], + pattern = root / prefix / "index.html" withFragment fragment[String] + ) + + given router: Router[Scenario.Id] = Router[Scenario.Id]( + routes = List(scenarioRoute), + identity[String], + identity[String], + identity[String], + routeFallback = _ => scenarios.head.id + )( + windowEvents.onPopState, + unsafeWindowOwner + ) + + def main(args: Array[String]): Unit = + given MessageCatalogue = messageCatalogue + + given ComponentContext with + val messages: MessageCatalogue = messageCatalogue + val style: StyleGuide = StyleGuide.default + + def container: HtmlElement = + div( + cls("h-full"), + div( + cls( + "fixed inset-y-0 z-50 flex w-72 flex-col" + ), + div( + cls( + "flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4" + ), + nav( + cls("flex flex-1 flex-col"), + ul( + role("list"), + cls("flex flex-1 flex-col gap-y-7"), + children <-- router.$currentPage.map(id => + scenarios.map(s => + li( + a( + href(s"#${s.id}"), + cls( + "group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold" + ), + if s.id == id then cls("bg-gray-50 text-indigo-600") + else + cls( + "text-gray-700 hover:text-indigo-600 hover:bg-gray-50" + ) + , + s.label + ) + ) + ) + ) + ) + ) + ) + ), + com.raquo.laminar.api.L.main( + cls("h-full pl-72"), + div( + cls( + "h-full max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8" + ), + child <-- router.$currentPage.map(scenarioMap(_).element) + ) + ) + ) + + val appContainer = dom.document.querySelector("#app") + render(appContainer, container) diff --git a/ui/jvm/src/main/scala/works/iterative/data/table/XSSFWriter.scala b/ui/jvm/src/main/scala/works/iterative/data/table/XSSFWriter.scala new file mode 100644 index 0000000..912e74e --- /dev/null +++ b/ui/jvm/src/main/scala/works/iterative/data/table/XSSFWriter.scala @@ -0,0 +1,195 @@ +package works.iterative.data.table + +import zio.* +import org.apache.poi.xssf.usermodel.XSSFRow +import org.apache.poi.ss.usermodel.CellStyle +import java.time.LocalDate +import org.apache.poi.xssf.usermodel.XSSFSheet +import org.apache.poi.xssf.usermodel.XSSFWorkbook +import org.apache.poi.xssf.usermodel.XSSFCellStyle +import org.apache.poi.ss.usermodel.IndexedColors +import org.apache.poi.ss.usermodel.FillPatternType +import org.apache.poi.ss.usermodel.BorderStyle +import org.apache.poi.xssf.usermodel.XSSFCell +import org.apache.poi.ss.usermodel.Hyperlink +import org.apache.poi.common.usermodel.HyperlinkType + +case class ExternalLink( + name: String, + href: String +) + +trait CellWriter[T]: + def write(cell: XSSFCell, value: T): Unit + +object CellWriter: + given CellWriter[String] with + def write(cell: XSSFCell, value: String) = cell.setCellValue(value) + + given CellWriter[Int] with + def write(cell: XSSFCell, value: Int) = cell.setCellValue(value) + + given CellWriter[LocalDate] with + def write(cell: XSSFCell, value: LocalDate) = cell.setCellValue(value) + + given CellWriter[ExternalLink] with + def write(cell: XSSFCell, value: ExternalLink) = + cell.setCellValue(value.name) + cell.setHyperlink( + cell.getSheet.getWorkbook.getCreationHelper.createHyperlink( + HyperlinkType.URL + ) + ) + cell.getHyperlink.setAddress(value.href) + + given optionalCellSetter[T](using CellWriter[T]): CellWriter[Option[T]] with + def write(cell: XSSFCell, value: Option[T]) = + value.foreach(summon[CellWriter[T]].write(cell, _)) + +case class ColumnSpec[T, U: CellWriter]( + id: String, + name: String, + get: T => U, + width: Int = 20 +): + def set(cell: XSSFCell, row: T) = + summon[CellWriter[U]].write(cell, get(row)) + +trait TableRowWriter[T]: + def writeHeaderCells(row: XSSFRow)(using TableCellStyles): UIO[Unit] + def writeDataCells(row: XSSFRow, value: T)(using TableCellStyles): UIO[Unit] + def columnSizes: List[Int] + +class ColumnSpecsTableRowWriter[T](specs: List[ColumnSpec[T, _]]) + extends TableRowWriter[T]: + override def writeHeaderCells(row: XSSFRow)(using styles: TableCellStyles) = + val headers = specs.map(_.name) + ZIO.succeed { + headers.zipWithIndex.foreach { case (header, index) => + val cell = row.createCell(index) + cell.setCellValue(header) + styles + .byName(TableCellStyles.HeaderStyleName) + .foreach(cell.setCellStyle) + } + } + + override def writeDataCells( + row: XSSFRow, + value: T + )(using styles: TableCellStyles) = + ZIO.succeed { + specs.zipWithIndex.map { case (spec, i) => + val cell = row.createCell(i) + spec.set(cell, value) + styles.byName(spec.id).foreach(cell.setCellStyle) + } + } + + override def columnSizes: List[Int] = + specs.map(_.width).toList + +type TableCellStyleDef = XSSFWorkbook => XSSFCellStyle + +trait TableCellStyles: + def byName(name: String): Option[CellStyle] + +object TableCellStyles: + val HeaderStyleName = "_header" + + val defaultHeaderStyle: TableCellStyleDef = (wb: XSSFWorkbook) => { + val headerStyle = wb.createCellStyle() + val font = wb.createFont() + font.setBold(true) + headerStyle.setFont(font) + + // set background color + headerStyle.setFillForegroundColor( + IndexedColors.GREY_25_PERCENT.getIndex() + ) + headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND) + + // set border style + headerStyle.setBorderTop(BorderStyle.MEDIUM) + headerStyle.setBorderBottom(BorderStyle.MEDIUM) + headerStyle.setBorderLeft(BorderStyle.MEDIUM) + headerStyle.setBorderRight(BorderStyle.MEDIUM) + headerStyle + } + + val dateStyle: TableCellStyleDef = (wb: XSSFWorkbook) => { + val dateCellStyle = wb.createCellStyle() + dateCellStyle.setDataFormat( + wb + .getCreationHelper() + .createDataFormat() + .getFormat("dd.MM.yyyy") + ) + dateCellStyle + } + + val wrapStyle: TableCellStyleDef = (wb: XSSFWorkbook) => { + // set cell style to shrink font size and truncate text + val wrapCellStyle = wb.createCellStyle() + wrapCellStyle.setWrapText(true) + wrapCellStyle + } + + def make(): UIO[TableCellStyles] = + ZIO.succeed( + new TableCellStyles: + override def byName(name: String): Option[CellStyle] = + None + ) + + def make( + wb: XSSFWorkbook, + styles: List[(String, TableCellStyleDef)] + ): UIO[TableCellStyles] = + ZIO + .collectAll(styles.map { case (name, styleDef) => + for style <- ZIO.succeed(styleDef(wb)) + yield name -> style + }) + .map { styles => + new TableCellStyles: + override def byName(name: String): Option[CellStyle] = + styles.toMap.get(name) + } + +class TableWorkbook private (workbook: XSSFWorkbook, styles: TableCellStyles): + def writeSheet[T](name: String, data: Iterable[T])(using + writer: TableRowWriter[T] + ): UIO[Unit] = + for + sheet <- ZIO.succeed(workbook.createSheet(name)) + headerRow <- ZIO.succeed(sheet.createRow(0)) + _ <- writer.writeHeaderCells(headerRow)(using styles) + _ <- ZIO.foreach(data.zipWithIndex) { case (value, index) => + val row = sheet.createRow(index + 1) + writer.writeDataCells(row, value)(using styles) + } + _ <- ZIO.foreach(writer.columnSizes.zipWithIndex) { case (size, index) => + ZIO.succeed(sheet.setColumnWidth(index, size * 256)) + } + yield () + + def toArray: UIO[Array[Byte]] = ZIO.succeed { + val out = new java.io.ByteArrayOutputStream() + workbook.write(out) + out.close() + out.toByteArray + } + +object TableWorkbook: + def make(): UIO[TableWorkbook] = + for + wb <- ZIO.succeed(XSSFWorkbook()) + styles <- TableCellStyles.make() + yield TableWorkbook(wb, styles) + + def make(styles: List[(String, TableCellStyleDef)]): UIO[TableWorkbook] = + for + wb <- ZIO.succeed(XSSFWorkbook()) + styles <- TableCellStyles.make(wb, styles) + yield TableWorkbook(wb, styles) diff --git a/ui/shared/src/main/scala/works/README.md b/ui/shared/src/main/scala/works/README.md new file mode 100644 index 0000000..e6d131d --- /dev/null +++ b/ui/shared/src/main/scala/works/README.md @@ -0,0 +1 @@ +This folder is intended as a place to quickly prototype features that should end up in the shared library. \ No newline at end of file diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala new file mode 100644 index 0000000..d2e0b4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag + +case class Bin[Source, +Value]( + label: String, + description: Option[String | HtmlElement], + color: ColorDef, + valueOf: Source => Value +): + def map[NewValue]( + f: Bin[Source, Value] => Source => NewValue + ): Bin[Source, NewValue] = + copy(valueOf = f(this)) + +case class Bins[T, U](bins: Seq[Bin[T, U]]): + def apply(v: T): Seq[U] = bins.map(_.valueOf(v)) + def map[A](r: Bin[T, U] => T => A): Bins[T, A] = + copy(bins = bins.map(_.map(r))) + + def renderSeparated( + r: Bin[T, U] => T => HtmlElement, + separator: => HtmlElement = span(" / "), + container: HtmlTag[org.scalajs.dom.html.Element] = span + )(v: T): HtmlElement = + container(interleave(map(r)(v), separator)) + + def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala new file mode 100644 index 0000000..a47f5ba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala @@ -0,0 +1,45 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait ButtonComponentsModule: + def buttons: ButtonComponents + + trait ButtonComponents: + def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + +trait DefaultButtonComponentsModule(using ctx: ComponentContext) + extends ButtonComponentsModule: + + override val buttons = new ButtonComponents: + + private inline def srHelp(id: String): Modifier[HtmlElement] = + ctx.messages + .opt(s"form.button.${id}.screenReaderHelp") + .map(sr => span(cls := "sr-only", sr)) + + override def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srHelp(id), + icon + ) + + override def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srHelp(id) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala new file mode 100644 index 0000000..15169f3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala @@ -0,0 +1,191 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom.html.UList +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind + +trait DashboardComponentsModule: + def dashboard: DashboardComponents + + // Only methods that create HtmlElements + trait DashboardComponents(using ComponentContext): + def section(label: String, children: Modifier[HtmlElement]): Div + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li + def cardInitials( + initials: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div + def count(label: String, value: Int, color: ColorDef): Span + def counts(children: HtmlElement*): Span + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + +trait DefaultDashboardComponentsModule(using ComponentContext) + extends DashboardComponentsModule: + override val dashboard: DashboardComponents = new DashboardComponents: + def section(label: String, children: Modifier[HtmlElement]): Div = + div( + cls("mt-3"), + h2( + cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), + label + ), + children + ) + + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = + ul( + cls( + "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + ), + children + ) + + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li = + li( + div( + cls("col-span-1 flex shadow-sm rounded-md"), + initials, + 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"), + div( + cls("text-gray-900 font-medium hover:text-gray-600 truncate"), + label + ), + p(cls("text-gray-500"), children) + ) + ) + ) + ) + + def cardInitials( + id: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div = + div( + cls( + "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" + ), + color.map(_(600).bg), + div(id), + children + ) + + def count(label: String, value: Int, color: ColorDef): Span = + span(color.text, title(label), s"${value}") + + def counts(children: HtmlElement*): Span = + span(interleave(children, span(" / "))) + + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div = + div( + cls("flex flex-col mt-2"), + div( + cls( + "align-middle min-w-full shadow sm:rounded-lg" + ), + L.table( + cls("min-w-full border-separate border-spacing-0"), + styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work + thead( + tr( + cls("border-t border-gray-200"), + headers.map(h => + th( + cls( + "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + h + ) + ) + ) + ), + tbody(cls("bg-white divide-y divide-gray-100"), sections) + ) + ) + ) + + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + td( + cls("bg-gray-50 border-gray-200 border-t border-b"), + cls( + "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + colSpan(cs), + label + ), + mods + ) + + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + mods, + td( + cls( + "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" + ), + label + ), + children.map(e => + td( + cls( + "whitespace-nowrap px-2 py-2 text-sm text-gray-500" + ), + e + ) + ) + ) + + private def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala new file mode 100644 index 0000000..4cff836 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala @@ -0,0 +1,56 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +import zio.* +import com.raquo.airstream.core.Observer +import scala.annotation.implicitNotFound + +trait EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] + +object EffectHandler: + given zioEffectHandler[Env, E, A](using + Runtime[Env] + ): Conversion[ZIOEffectHandler[Env, E, A], EffectHandler[E, A]] with + def apply(h: ZIOEffectHandler[Env, E, A]): EffectHandler[E, A] = + LaminarZIOEffectHandler(h) + + def loggingHandler[E, A](name: String)( + underlying: EffectHandler[E, A] + ): EffectHandler[E, A] = + new EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + underlying( + effects.debugWithName(s"$name effects").debugLog(), + actions.debugWithName(s"$name actions").debugLog() + ) + +class LaminarZIOEffectHandler[Env, E, A](handler: ZIOEffectHandler[Env, E, A])( + using runtime: Runtime[Env] +) extends EffectHandler[E, A]: + + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + onMountCallback(ctx => + effects.foreach { effect => + Unsafe.unsafe { implicit unsafe => + runtime.unsafe + .runToFuture( + handler.handle(effect).either.runForeach { + case Right(a) => ZIO.succeed(actions.onNext(a)) + case Left(e) => ZIO.succeed(actions.onError(e)) + } + ) + } + }(ctx.owner) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala new file mode 100644 index 0000000..df42132 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala @@ -0,0 +1,97 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.raquo.laminar.keys.ReactiveProp +import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent +import org.scalajs.dom.html +import com.raquo.laminar.modifiers.KeyUpdater + +trait FormComponentsModule extends LocalDateSelectModule: + def forms: FormComponents + + trait FormComponents: + def form(mods: Modifier[HtmlElement]*): HtmlElement + + def searchField(id: String, placeholderText: Option[String] = None)( + mods: Modifier[HtmlElement]* + ): HtmlElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement + +trait DefaultFormComponentsModule(using ctx: ComponentContext) + extends FormComponentsModule: + self: IconsModule => + override val forms = new FormComponents: + + override def form( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + override def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label( + forId := id, + cls := "sr-only", + "Hledat" + ), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + icons.`search-solid`() + ), + input( + tpe := "search", + name := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) + + override def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + name(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala new file mode 100644 index 0000000..32fda8a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala @@ -0,0 +1,109 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.CustomAttrs + +trait IconsModule: + def icons: Icons + + trait Icons: + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement + def close(mods: Modifier[SvgElement]*): SvgElement + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement + def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement + +trait DefaultIconsModule(using ComponentContext) extends IconsModule: + override val icons: Icons = new Icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("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" + ) + ) + ) + + override def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala new file mode 100644 index 0000000..f7bacb1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -0,0 +1,35 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +abstract class LaminarComponent[M, A, E]( + effectHandler: EffectHandler[E, A] +) extends Module[M, A, E]: + def render(m: Signal[M], actions: Observer[A]): HtmlElement + + val element: HtmlElement = + val actions = new EventBus[A] + + val zero @ (_, effect) = init + + val initialEffect$ = (effect match + case Some(e) => EventStream.fromValue(e) + case _ => EventStream.empty + ) + + val actions$ = actions.events.recover(handleFailure) + + val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + handle(a, m) + } + + val nextEffects$ = processor$.changes.collect { case (_, Some(e)) => e } + + val model$ = processor$.map(_._1) + + val effect$ = EventStream.merge(initialEffect$, nextEffects$) + + render(model$, actions.writer).amend( + effectHandler(effect$, actions.writer) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala new file mode 100644 index 0000000..98e31b9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala @@ -0,0 +1,114 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html.Paragraph +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.nodes.TextNode + +trait ListComponentsModule: + + def list: ListComponents + + trait ListComponents(using ComponentContext): + def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] + def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None + ): Li + def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] + def listSection(header: String, list: HtmlElement): Div + def navigation(sections: Modifier[HtmlElement]): HtmlElement + +trait DefaultListComponentsModule(using ComponentContext) + extends ListComponentsModule: + override val list: ListComponents = new ListComponents: + override def label( + text: String, + color: ColorKind + ): ReactiveHtmlElement[Paragraph] = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + text + ) + + override def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None + ): Li = + li( + cls("group"), + div( + cls( + "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" + ), + avatar.map(a => + div( + cls("flex-shrink-0"), + div( + cls( + "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" + ), + a + ) + ) + ), + div( + cls("flex-1 min-w-0"), + p( + cls("text-sm font-medium text-gray-900"), + title, + span(cls("float-right"), right) + ), + subtitle.map(st => + p( + cls("text-sm text-gray-500 truncate"), + st + ) + ) + ) + ) + ) + + override def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = + ul( + cls("relative z-0 divide-y divide-gray-200"), + role("list"), + children + ) + + override def listSection( + header: String, + list: HtmlElement + ): Div = + div( + cls("relative"), + div( + 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 + ), + list + ) + + override def navigation(sections: Modifier[HtmlElement]): HtmlElement = + nav( + cls("flex-1 min-h-0 overflow-y-auto"), + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala new file mode 100644 index 0000000..31d860d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -0,0 +1,72 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.raquo.laminar.keys.ReactiveProp +import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent +import org.scalajs.dom.html +import com.raquo.laminar.modifiers.KeyUpdater + +trait LocalDateSelectModule: + val localDateSelect: LocalDateSelect = new LocalDateSelect + + class LocalDateSelect: + import LocalDateSelect.* + + def valueUpdater( + signal: Signal[Option[LocalDate]] + ): KeyUpdater.PropUpdater[String, String] = + L.value <-- signal.map(_.map(formatDate).getOrElse("")) + + // Does not work in `controlled` + // Laminar refuses the custom prop, requries its own `value` or `checked` + val value: ReactiveProp[Option[LocalDate], String] = + customProp("value", OptLocalDateAsStringCodec) + + val min: ReactiveProp[LocalDate, String] = + customProp("min", LocalDateAsStringCodec) + + val max: ReactiveProp[LocalDate, String] = + customProp("max", LocalDateAsStringCodec) + + val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => + d + } + + val onOptInput + : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + onInput.mapToValue.setAsValue.map(parseDate) + + object LocalDateSelect: + import java.time.format.DateTimeFormatter + import java.time.LocalDate + import com.raquo.domtypes.generic.codecs.Codec + + private val formatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd") + + private def parseDate(date: String): Option[LocalDate] = + import scala.util.Try + if date.isEmpty then None + else Try(LocalDate.parse(date, formatter)).toOption + + private def formatDate(date: LocalDate): String = + formatter.format(date) + + object LocalDateAsStringCodec extends Codec[LocalDate, String]: + override def decode(domValue: String): LocalDate = + parseDate(domValue).orNull + + override def encode(scalaValue: LocalDate): String = + formatDate(scalaValue) + + object OptLocalDateAsStringCodec extends Codec[Option[LocalDate], String]: + override def decode(domValue: String): Option[LocalDate] = + parseDate(domValue) + + override def encode(scalaValue: Option[LocalDate]): String = + scalaValue.map(formatDate).getOrElse("") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala new file mode 100644 index 0000000..3f3e7d0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala @@ -0,0 +1,71 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait PageComponentsModule: + + val page: PageComponents + + trait PageComponents: + def container( + children: Modifier[HtmlElement]* + ): HtmlElement + + def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement + + def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement] = emptyMod, + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement + + /** Visage for clickable text, like links + */ + def clickable: Modifier[HtmlElement] + +trait DefaultPageComponentsModule(using ComponentContext) + extends PageComponentsModule: + + override val page: PageComponents = new PageComponents: + override def container( + children: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), + children + ) + + override def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement = + div( + cls("p-8 bg-gray-100 h-full"), + header, + children + ) + + override def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + div( + cls("pb-5 border-b border-gray-200"), + div(cls("float-right"), right), + h1( + cls("text-2xl leading-6 font-medium text-gray-900"), + title + ), + subtitle.map( + p( + cls("text-sm font-medium text-gray-500"), + _ + ) + ) + ) + + override def clickable: Modifier[HtmlElement] = + cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") 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 index 6cda48b..f23f0e8 100644 --- 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 @@ -9,7 +9,7 @@ enum DisplayClass: case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` + `inline-table`, `table-caption` object ShowUpFrom: inline def apply( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/experimental/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/experimental/Color.scala new file mode 100644 index 0000000..45261a7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/experimental/Color.scala @@ -0,0 +1,162 @@ +package works.iterative.ui.components.tailwind.experimental + +import scala.util.NotGiven +import com.raquo.domtypes.generic.Modifier +import org.scalajs.dom.SVGElement + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" + +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") + +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") + +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) + +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..7777c7a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package laminar + +import com.raquo.laminar.api.L.{*, given} + +object LaminarExtensions: + given colorToCSS: Conversion[experimental.Color, Modifier[HtmlElement]] with + def apply(c: experimental.Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[experimental.Color, Modifier[SvgElement]] with + def apply(c: experimental.Color) = svg.cls(c.toCSS) + + given colorSignalToCSS + : Conversion[Signal[experimental.Color], Modifier[HtmlElement]] with + def apply(c: Signal[experimental.Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS + : Conversion[Signal[experimental.Color], Modifier[SvgElement]] with + def apply(c: Signal[experimental.Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala new file mode 100644 index 0000000..6e005af --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala @@ -0,0 +1,78 @@ +package works.iterative.ui +package scenarios + +import com.raquo.laminar.api.L + +import scala.scalajs.js.annotation.{JSExportTopLevel, JSImport} +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom +import org.scalajs.dom.html + +import scala.scalajs.js +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.StyleGuide +import works.iterative.ui.components.tailwind.experimental.Color +import works.iterative.ui.components.tailwind.experimental.ColorWeight + +object Scenario: + type Id = String + +trait Scenario: + trait ScenarioContext: + def events: Observer[Any] + + def id: Scenario.Id + + def label: String + + def element(using ComponentContext): HtmlElement + +trait ScenarioExample: + def title: String + def element(using ComponentContext): HtmlElement + +object ScenarioExample: + def apply( + t: String, + elem: ComponentContext ?=> HtmlElement + ): ScenarioExample = + new ScenarioExample: + override val title: String = t + override def element(using ComponentContext): HtmlElement = elem + +trait ScenarioExamples: + self: Scenario => + + protected def examples(using + ScenarioContext, + ComponentContext + ): List[ScenarioExample] + + override def element(using ComponentContext): HtmlElement = + val eventBus: EventBus[Any] = EventBus[Any]() + + given sc: ScenarioContext = new ScenarioContext: + def events: Observer[Any] = eventBus.writer + + div( + cls("flex flex-col space-y-5"), + eventBus.events --> { e => + org.scalajs.dom.console.log(s"action: ${e.toString}") + }, + examples.map(se => example(se.title, se.element)) + ) + + def example(t: String, c: HtmlElement): Div = + div( + cls("bg-white overflow-hidden shadow rounded-lg"), + div( + cls("px-4 py-5 sm:p-6"), + h3( + cls("text-lg leading-6 font-medium text-gray-900 border-b"), + t + ), + div(cls("px-5 py-5"), c) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala new file mode 100644 index 0000000..90e446c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala @@ -0,0 +1,107 @@ +package works.iterative.ui.scenarios + +import scala.scalajs.js.annotation.{JSExportTopLevel, JSImport} +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom + +import scala.scalajs.js +import works.iterative.ui.JsonMessageCatalogue +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.StyleGuide +import ui.components.tailwind.TailwindSupport +import com.raquo.waypoint.* + +import scala.scalajs.js.Dictionary + +trait ScenarioMain( + prefix: String, + scenarios: List[Scenario], + messages: js.Any, + css: js.Any +) extends TailwindSupport: + + val scenarioMap: Map[Scenario.Id, Scenario] = + scenarios.map(s => (s.id, s)).toMap + + val messageCatalogue: MessageCatalogue = new JsonMessageCatalogue: + override val messages: Dictionary[String] = + ScenarioMain.this.messages.asInstanceOf[js.Dictionary[String]] + + val scenarioRoute: Route[Scenario.Id, String] = + Route.onlyFragment[Scenario.Id, String]( + identity[String], + identity[String], + pattern = root / prefix / "index.html" withFragment fragment[String] + ) + + given router: Router[Scenario.Id] = Router[Scenario.Id]( + routes = List(scenarioRoute), + identity[String], + identity[String], + identity[String], + routeFallback = _ => scenarios.head.id + )( + windowEvents.onPopState, + unsafeWindowOwner + ) + + def main(args: Array[String]): Unit = + given MessageCatalogue = messageCatalogue + + given ComponentContext with + val messages: MessageCatalogue = messageCatalogue + val style: StyleGuide = StyleGuide.default + + def container: HtmlElement = + div( + cls("h-full"), + div( + cls( + "fixed inset-y-0 z-50 flex w-72 flex-col" + ), + div( + cls( + "flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4" + ), + nav( + cls("flex flex-1 flex-col"), + ul( + role("list"), + cls("flex flex-1 flex-col gap-y-7"), + children <-- router.$currentPage.map(id => + scenarios.map(s => + li( + a( + href(s"#${s.id}"), + cls( + "group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold" + ), + if s.id == id then cls("bg-gray-50 text-indigo-600") + else + cls( + "text-gray-700 hover:text-indigo-600 hover:bg-gray-50" + ) + , + s.label + ) + ) + ) + ) + ) + ) + ) + ), + com.raquo.laminar.api.L.main( + cls("h-full pl-72"), + div( + cls( + "h-full max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8" + ), + child <-- router.$currentPage.map(scenarioMap(_).element) + ) + ) + ) + + val appContainer = dom.document.querySelector("#app") + render(appContainer, container) diff --git a/ui/jvm/src/main/scala/works/iterative/data/table/XSSFWriter.scala b/ui/jvm/src/main/scala/works/iterative/data/table/XSSFWriter.scala new file mode 100644 index 0000000..912e74e --- /dev/null +++ b/ui/jvm/src/main/scala/works/iterative/data/table/XSSFWriter.scala @@ -0,0 +1,195 @@ +package works.iterative.data.table + +import zio.* +import org.apache.poi.xssf.usermodel.XSSFRow +import org.apache.poi.ss.usermodel.CellStyle +import java.time.LocalDate +import org.apache.poi.xssf.usermodel.XSSFSheet +import org.apache.poi.xssf.usermodel.XSSFWorkbook +import org.apache.poi.xssf.usermodel.XSSFCellStyle +import org.apache.poi.ss.usermodel.IndexedColors +import org.apache.poi.ss.usermodel.FillPatternType +import org.apache.poi.ss.usermodel.BorderStyle +import org.apache.poi.xssf.usermodel.XSSFCell +import org.apache.poi.ss.usermodel.Hyperlink +import org.apache.poi.common.usermodel.HyperlinkType + +case class ExternalLink( + name: String, + href: String +) + +trait CellWriter[T]: + def write(cell: XSSFCell, value: T): Unit + +object CellWriter: + given CellWriter[String] with + def write(cell: XSSFCell, value: String) = cell.setCellValue(value) + + given CellWriter[Int] with + def write(cell: XSSFCell, value: Int) = cell.setCellValue(value) + + given CellWriter[LocalDate] with + def write(cell: XSSFCell, value: LocalDate) = cell.setCellValue(value) + + given CellWriter[ExternalLink] with + def write(cell: XSSFCell, value: ExternalLink) = + cell.setCellValue(value.name) + cell.setHyperlink( + cell.getSheet.getWorkbook.getCreationHelper.createHyperlink( + HyperlinkType.URL + ) + ) + cell.getHyperlink.setAddress(value.href) + + given optionalCellSetter[T](using CellWriter[T]): CellWriter[Option[T]] with + def write(cell: XSSFCell, value: Option[T]) = + value.foreach(summon[CellWriter[T]].write(cell, _)) + +case class ColumnSpec[T, U: CellWriter]( + id: String, + name: String, + get: T => U, + width: Int = 20 +): + def set(cell: XSSFCell, row: T) = + summon[CellWriter[U]].write(cell, get(row)) + +trait TableRowWriter[T]: + def writeHeaderCells(row: XSSFRow)(using TableCellStyles): UIO[Unit] + def writeDataCells(row: XSSFRow, value: T)(using TableCellStyles): UIO[Unit] + def columnSizes: List[Int] + +class ColumnSpecsTableRowWriter[T](specs: List[ColumnSpec[T, _]]) + extends TableRowWriter[T]: + override def writeHeaderCells(row: XSSFRow)(using styles: TableCellStyles) = + val headers = specs.map(_.name) + ZIO.succeed { + headers.zipWithIndex.foreach { case (header, index) => + val cell = row.createCell(index) + cell.setCellValue(header) + styles + .byName(TableCellStyles.HeaderStyleName) + .foreach(cell.setCellStyle) + } + } + + override def writeDataCells( + row: XSSFRow, + value: T + )(using styles: TableCellStyles) = + ZIO.succeed { + specs.zipWithIndex.map { case (spec, i) => + val cell = row.createCell(i) + spec.set(cell, value) + styles.byName(spec.id).foreach(cell.setCellStyle) + } + } + + override def columnSizes: List[Int] = + specs.map(_.width).toList + +type TableCellStyleDef = XSSFWorkbook => XSSFCellStyle + +trait TableCellStyles: + def byName(name: String): Option[CellStyle] + +object TableCellStyles: + val HeaderStyleName = "_header" + + val defaultHeaderStyle: TableCellStyleDef = (wb: XSSFWorkbook) => { + val headerStyle = wb.createCellStyle() + val font = wb.createFont() + font.setBold(true) + headerStyle.setFont(font) + + // set background color + headerStyle.setFillForegroundColor( + IndexedColors.GREY_25_PERCENT.getIndex() + ) + headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND) + + // set border style + headerStyle.setBorderTop(BorderStyle.MEDIUM) + headerStyle.setBorderBottom(BorderStyle.MEDIUM) + headerStyle.setBorderLeft(BorderStyle.MEDIUM) + headerStyle.setBorderRight(BorderStyle.MEDIUM) + headerStyle + } + + val dateStyle: TableCellStyleDef = (wb: XSSFWorkbook) => { + val dateCellStyle = wb.createCellStyle() + dateCellStyle.setDataFormat( + wb + .getCreationHelper() + .createDataFormat() + .getFormat("dd.MM.yyyy") + ) + dateCellStyle + } + + val wrapStyle: TableCellStyleDef = (wb: XSSFWorkbook) => { + // set cell style to shrink font size and truncate text + val wrapCellStyle = wb.createCellStyle() + wrapCellStyle.setWrapText(true) + wrapCellStyle + } + + def make(): UIO[TableCellStyles] = + ZIO.succeed( + new TableCellStyles: + override def byName(name: String): Option[CellStyle] = + None + ) + + def make( + wb: XSSFWorkbook, + styles: List[(String, TableCellStyleDef)] + ): UIO[TableCellStyles] = + ZIO + .collectAll(styles.map { case (name, styleDef) => + for style <- ZIO.succeed(styleDef(wb)) + yield name -> style + }) + .map { styles => + new TableCellStyles: + override def byName(name: String): Option[CellStyle] = + styles.toMap.get(name) + } + +class TableWorkbook private (workbook: XSSFWorkbook, styles: TableCellStyles): + def writeSheet[T](name: String, data: Iterable[T])(using + writer: TableRowWriter[T] + ): UIO[Unit] = + for + sheet <- ZIO.succeed(workbook.createSheet(name)) + headerRow <- ZIO.succeed(sheet.createRow(0)) + _ <- writer.writeHeaderCells(headerRow)(using styles) + _ <- ZIO.foreach(data.zipWithIndex) { case (value, index) => + val row = sheet.createRow(index + 1) + writer.writeDataCells(row, value)(using styles) + } + _ <- ZIO.foreach(writer.columnSizes.zipWithIndex) { case (size, index) => + ZIO.succeed(sheet.setColumnWidth(index, size * 256)) + } + yield () + + def toArray: UIO[Array[Byte]] = ZIO.succeed { + val out = new java.io.ByteArrayOutputStream() + workbook.write(out) + out.close() + out.toByteArray + } + +object TableWorkbook: + def make(): UIO[TableWorkbook] = + for + wb <- ZIO.succeed(XSSFWorkbook()) + styles <- TableCellStyles.make() + yield TableWorkbook(wb, styles) + + def make(styles: List[(String, TableCellStyleDef)]): UIO[TableWorkbook] = + for + wb <- ZIO.succeed(XSSFWorkbook()) + styles <- TableCellStyles.make(wb, styles) + yield TableWorkbook(wb, styles) diff --git a/ui/shared/src/main/scala/works/README.md b/ui/shared/src/main/scala/works/README.md new file mode 100644 index 0000000..e6d131d --- /dev/null +++ b/ui/shared/src/main/scala/works/README.md @@ -0,0 +1 @@ +This folder is intended as a place to quickly prototype features that should end up in the shared library. \ No newline at end of file diff --git a/ui/shared/src/main/scala/works/iterative/ui/Module.scala b/ui/shared/src/main/scala/works/iterative/ui/Module.scala new file mode 100644 index 0000000..6a44b4a --- /dev/null +++ b/ui/shared/src/main/scala/works/iterative/ui/Module.scala @@ -0,0 +1,10 @@ +package works.iterative.ui + +trait Module[Model, Action, Effect]: + // Define initial model and effect + def init: (Model, Option[Effect]) + // Define how to handle actions to build new model and run effects + def handle(action: Action, model: Model): (Model, Option[Effect]) + // Optionally define how to handle failures. + // To be used by implementations to allow module to display error messages. + def handleFailure: PartialFunction[Throwable, Option[Action]] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala new file mode 100644 index 0000000..d2e0b4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag + +case class Bin[Source, +Value]( + label: String, + description: Option[String | HtmlElement], + color: ColorDef, + valueOf: Source => Value +): + def map[NewValue]( + f: Bin[Source, Value] => Source => NewValue + ): Bin[Source, NewValue] = + copy(valueOf = f(this)) + +case class Bins[T, U](bins: Seq[Bin[T, U]]): + def apply(v: T): Seq[U] = bins.map(_.valueOf(v)) + def map[A](r: Bin[T, U] => T => A): Bins[T, A] = + copy(bins = bins.map(_.map(r))) + + def renderSeparated( + r: Bin[T, U] => T => HtmlElement, + separator: => HtmlElement = span(" / "), + container: HtmlTag[org.scalajs.dom.html.Element] = span + )(v: T): HtmlElement = + container(interleave(map(r)(v), separator)) + + def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala new file mode 100644 index 0000000..a47f5ba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala @@ -0,0 +1,45 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait ButtonComponentsModule: + def buttons: ButtonComponents + + trait ButtonComponents: + def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + +trait DefaultButtonComponentsModule(using ctx: ComponentContext) + extends ButtonComponentsModule: + + override val buttons = new ButtonComponents: + + private inline def srHelp(id: String): Modifier[HtmlElement] = + ctx.messages + .opt(s"form.button.${id}.screenReaderHelp") + .map(sr => span(cls := "sr-only", sr)) + + override def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srHelp(id), + icon + ) + + override def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srHelp(id) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala new file mode 100644 index 0000000..15169f3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala @@ -0,0 +1,191 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom.html.UList +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind + +trait DashboardComponentsModule: + def dashboard: DashboardComponents + + // Only methods that create HtmlElements + trait DashboardComponents(using ComponentContext): + def section(label: String, children: Modifier[HtmlElement]): Div + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li + def cardInitials( + initials: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div + def count(label: String, value: Int, color: ColorDef): Span + def counts(children: HtmlElement*): Span + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + +trait DefaultDashboardComponentsModule(using ComponentContext) + extends DashboardComponentsModule: + override val dashboard: DashboardComponents = new DashboardComponents: + def section(label: String, children: Modifier[HtmlElement]): Div = + div( + cls("mt-3"), + h2( + cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), + label + ), + children + ) + + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = + ul( + cls( + "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + ), + children + ) + + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li = + li( + div( + cls("col-span-1 flex shadow-sm rounded-md"), + initials, + 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"), + div( + cls("text-gray-900 font-medium hover:text-gray-600 truncate"), + label + ), + p(cls("text-gray-500"), children) + ) + ) + ) + ) + + def cardInitials( + id: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div = + div( + cls( + "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" + ), + color.map(_(600).bg), + div(id), + children + ) + + def count(label: String, value: Int, color: ColorDef): Span = + span(color.text, title(label), s"${value}") + + def counts(children: HtmlElement*): Span = + span(interleave(children, span(" / "))) + + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div = + div( + cls("flex flex-col mt-2"), + div( + cls( + "align-middle min-w-full shadow sm:rounded-lg" + ), + L.table( + cls("min-w-full border-separate border-spacing-0"), + styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work + thead( + tr( + cls("border-t border-gray-200"), + headers.map(h => + th( + cls( + "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + h + ) + ) + ) + ), + tbody(cls("bg-white divide-y divide-gray-100"), sections) + ) + ) + ) + + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + td( + cls("bg-gray-50 border-gray-200 border-t border-b"), + cls( + "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + colSpan(cs), + label + ), + mods + ) + + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + mods, + td( + cls( + "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" + ), + label + ), + children.map(e => + td( + cls( + "whitespace-nowrap px-2 py-2 text-sm text-gray-500" + ), + e + ) + ) + ) + + private def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala new file mode 100644 index 0000000..4cff836 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala @@ -0,0 +1,56 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +import zio.* +import com.raquo.airstream.core.Observer +import scala.annotation.implicitNotFound + +trait EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] + +object EffectHandler: + given zioEffectHandler[Env, E, A](using + Runtime[Env] + ): Conversion[ZIOEffectHandler[Env, E, A], EffectHandler[E, A]] with + def apply(h: ZIOEffectHandler[Env, E, A]): EffectHandler[E, A] = + LaminarZIOEffectHandler(h) + + def loggingHandler[E, A](name: String)( + underlying: EffectHandler[E, A] + ): EffectHandler[E, A] = + new EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + underlying( + effects.debugWithName(s"$name effects").debugLog(), + actions.debugWithName(s"$name actions").debugLog() + ) + +class LaminarZIOEffectHandler[Env, E, A](handler: ZIOEffectHandler[Env, E, A])( + using runtime: Runtime[Env] +) extends EffectHandler[E, A]: + + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + onMountCallback(ctx => + effects.foreach { effect => + Unsafe.unsafe { implicit unsafe => + runtime.unsafe + .runToFuture( + handler.handle(effect).either.runForeach { + case Right(a) => ZIO.succeed(actions.onNext(a)) + case Left(e) => ZIO.succeed(actions.onError(e)) + } + ) + } + }(ctx.owner) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala new file mode 100644 index 0000000..df42132 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala @@ -0,0 +1,97 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.raquo.laminar.keys.ReactiveProp +import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent +import org.scalajs.dom.html +import com.raquo.laminar.modifiers.KeyUpdater + +trait FormComponentsModule extends LocalDateSelectModule: + def forms: FormComponents + + trait FormComponents: + def form(mods: Modifier[HtmlElement]*): HtmlElement + + def searchField(id: String, placeholderText: Option[String] = None)( + mods: Modifier[HtmlElement]* + ): HtmlElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement + +trait DefaultFormComponentsModule(using ctx: ComponentContext) + extends FormComponentsModule: + self: IconsModule => + override val forms = new FormComponents: + + override def form( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + override def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label( + forId := id, + cls := "sr-only", + "Hledat" + ), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + icons.`search-solid`() + ), + input( + tpe := "search", + name := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) + + override def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + name(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala new file mode 100644 index 0000000..32fda8a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala @@ -0,0 +1,109 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.CustomAttrs + +trait IconsModule: + def icons: Icons + + trait Icons: + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement + def close(mods: Modifier[SvgElement]*): SvgElement + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement + def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement + +trait DefaultIconsModule(using ComponentContext) extends IconsModule: + override val icons: Icons = new Icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("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" + ) + ) + ) + + override def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala new file mode 100644 index 0000000..f7bacb1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -0,0 +1,35 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +abstract class LaminarComponent[M, A, E]( + effectHandler: EffectHandler[E, A] +) extends Module[M, A, E]: + def render(m: Signal[M], actions: Observer[A]): HtmlElement + + val element: HtmlElement = + val actions = new EventBus[A] + + val zero @ (_, effect) = init + + val initialEffect$ = (effect match + case Some(e) => EventStream.fromValue(e) + case _ => EventStream.empty + ) + + val actions$ = actions.events.recover(handleFailure) + + val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + handle(a, m) + } + + val nextEffects$ = processor$.changes.collect { case (_, Some(e)) => e } + + val model$ = processor$.map(_._1) + + val effect$ = EventStream.merge(initialEffect$, nextEffects$) + + render(model$, actions.writer).amend( + effectHandler(effect$, actions.writer) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala new file mode 100644 index 0000000..98e31b9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala @@ -0,0 +1,114 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html.Paragraph +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.nodes.TextNode + +trait ListComponentsModule: + + def list: ListComponents + + trait ListComponents(using ComponentContext): + def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] + def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None + ): Li + def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] + def listSection(header: String, list: HtmlElement): Div + def navigation(sections: Modifier[HtmlElement]): HtmlElement + +trait DefaultListComponentsModule(using ComponentContext) + extends ListComponentsModule: + override val list: ListComponents = new ListComponents: + override def label( + text: String, + color: ColorKind + ): ReactiveHtmlElement[Paragraph] = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + text + ) + + override def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None + ): Li = + li( + cls("group"), + div( + cls( + "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" + ), + avatar.map(a => + div( + cls("flex-shrink-0"), + div( + cls( + "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" + ), + a + ) + ) + ), + div( + cls("flex-1 min-w-0"), + p( + cls("text-sm font-medium text-gray-900"), + title, + span(cls("float-right"), right) + ), + subtitle.map(st => + p( + cls("text-sm text-gray-500 truncate"), + st + ) + ) + ) + ) + ) + + override def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = + ul( + cls("relative z-0 divide-y divide-gray-200"), + role("list"), + children + ) + + override def listSection( + header: String, + list: HtmlElement + ): Div = + div( + cls("relative"), + div( + 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 + ), + list + ) + + override def navigation(sections: Modifier[HtmlElement]): HtmlElement = + nav( + cls("flex-1 min-h-0 overflow-y-auto"), + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala new file mode 100644 index 0000000..31d860d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -0,0 +1,72 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.raquo.laminar.keys.ReactiveProp +import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent +import org.scalajs.dom.html +import com.raquo.laminar.modifiers.KeyUpdater + +trait LocalDateSelectModule: + val localDateSelect: LocalDateSelect = new LocalDateSelect + + class LocalDateSelect: + import LocalDateSelect.* + + def valueUpdater( + signal: Signal[Option[LocalDate]] + ): KeyUpdater.PropUpdater[String, String] = + L.value <-- signal.map(_.map(formatDate).getOrElse("")) + + // Does not work in `controlled` + // Laminar refuses the custom prop, requries its own `value` or `checked` + val value: ReactiveProp[Option[LocalDate], String] = + customProp("value", OptLocalDateAsStringCodec) + + val min: ReactiveProp[LocalDate, String] = + customProp("min", LocalDateAsStringCodec) + + val max: ReactiveProp[LocalDate, String] = + customProp("max", LocalDateAsStringCodec) + + val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => + d + } + + val onOptInput + : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + onInput.mapToValue.setAsValue.map(parseDate) + + object LocalDateSelect: + import java.time.format.DateTimeFormatter + import java.time.LocalDate + import com.raquo.domtypes.generic.codecs.Codec + + private val formatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd") + + private def parseDate(date: String): Option[LocalDate] = + import scala.util.Try + if date.isEmpty then None + else Try(LocalDate.parse(date, formatter)).toOption + + private def formatDate(date: LocalDate): String = + formatter.format(date) + + object LocalDateAsStringCodec extends Codec[LocalDate, String]: + override def decode(domValue: String): LocalDate = + parseDate(domValue).orNull + + override def encode(scalaValue: LocalDate): String = + formatDate(scalaValue) + + object OptLocalDateAsStringCodec extends Codec[Option[LocalDate], String]: + override def decode(domValue: String): Option[LocalDate] = + parseDate(domValue) + + override def encode(scalaValue: Option[LocalDate]): String = + scalaValue.map(formatDate).getOrElse("") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala new file mode 100644 index 0000000..3f3e7d0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala @@ -0,0 +1,71 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait PageComponentsModule: + + val page: PageComponents + + trait PageComponents: + def container( + children: Modifier[HtmlElement]* + ): HtmlElement + + def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement + + def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement] = emptyMod, + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement + + /** Visage for clickable text, like links + */ + def clickable: Modifier[HtmlElement] + +trait DefaultPageComponentsModule(using ComponentContext) + extends PageComponentsModule: + + override val page: PageComponents = new PageComponents: + override def container( + children: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), + children + ) + + override def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement = + div( + cls("p-8 bg-gray-100 h-full"), + header, + children + ) + + override def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + div( + cls("pb-5 border-b border-gray-200"), + div(cls("float-right"), right), + h1( + cls("text-2xl leading-6 font-medium text-gray-900"), + title + ), + subtitle.map( + p( + cls("text-sm font-medium text-gray-500"), + _ + ) + ) + ) + + override def clickable: Modifier[HtmlElement] = + cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") 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 index 6cda48b..f23f0e8 100644 --- 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 @@ -9,7 +9,7 @@ enum DisplayClass: case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` + `inline-table`, `table-caption` object ShowUpFrom: inline def apply( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/experimental/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/experimental/Color.scala new file mode 100644 index 0000000..45261a7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/experimental/Color.scala @@ -0,0 +1,162 @@ +package works.iterative.ui.components.tailwind.experimental + +import scala.util.NotGiven +import com.raquo.domtypes.generic.Modifier +import org.scalajs.dom.SVGElement + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" + +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") + +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") + +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) + +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..7777c7a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package laminar + +import com.raquo.laminar.api.L.{*, given} + +object LaminarExtensions: + given colorToCSS: Conversion[experimental.Color, Modifier[HtmlElement]] with + def apply(c: experimental.Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[experimental.Color, Modifier[SvgElement]] with + def apply(c: experimental.Color) = svg.cls(c.toCSS) + + given colorSignalToCSS + : Conversion[Signal[experimental.Color], Modifier[HtmlElement]] with + def apply(c: Signal[experimental.Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS + : Conversion[Signal[experimental.Color], Modifier[SvgElement]] with + def apply(c: Signal[experimental.Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala new file mode 100644 index 0000000..6e005af --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala @@ -0,0 +1,78 @@ +package works.iterative.ui +package scenarios + +import com.raquo.laminar.api.L + +import scala.scalajs.js.annotation.{JSExportTopLevel, JSImport} +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom +import org.scalajs.dom.html + +import scala.scalajs.js +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.StyleGuide +import works.iterative.ui.components.tailwind.experimental.Color +import works.iterative.ui.components.tailwind.experimental.ColorWeight + +object Scenario: + type Id = String + +trait Scenario: + trait ScenarioContext: + def events: Observer[Any] + + def id: Scenario.Id + + def label: String + + def element(using ComponentContext): HtmlElement + +trait ScenarioExample: + def title: String + def element(using ComponentContext): HtmlElement + +object ScenarioExample: + def apply( + t: String, + elem: ComponentContext ?=> HtmlElement + ): ScenarioExample = + new ScenarioExample: + override val title: String = t + override def element(using ComponentContext): HtmlElement = elem + +trait ScenarioExamples: + self: Scenario => + + protected def examples(using + ScenarioContext, + ComponentContext + ): List[ScenarioExample] + + override def element(using ComponentContext): HtmlElement = + val eventBus: EventBus[Any] = EventBus[Any]() + + given sc: ScenarioContext = new ScenarioContext: + def events: Observer[Any] = eventBus.writer + + div( + cls("flex flex-col space-y-5"), + eventBus.events --> { e => + org.scalajs.dom.console.log(s"action: ${e.toString}") + }, + examples.map(se => example(se.title, se.element)) + ) + + def example(t: String, c: HtmlElement): Div = + div( + cls("bg-white overflow-hidden shadow rounded-lg"), + div( + cls("px-4 py-5 sm:p-6"), + h3( + cls("text-lg leading-6 font-medium text-gray-900 border-b"), + t + ), + div(cls("px-5 py-5"), c) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala new file mode 100644 index 0000000..90e446c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala @@ -0,0 +1,107 @@ +package works.iterative.ui.scenarios + +import scala.scalajs.js.annotation.{JSExportTopLevel, JSImport} +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom + +import scala.scalajs.js +import works.iterative.ui.JsonMessageCatalogue +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.StyleGuide +import ui.components.tailwind.TailwindSupport +import com.raquo.waypoint.* + +import scala.scalajs.js.Dictionary + +trait ScenarioMain( + prefix: String, + scenarios: List[Scenario], + messages: js.Any, + css: js.Any +) extends TailwindSupport: + + val scenarioMap: Map[Scenario.Id, Scenario] = + scenarios.map(s => (s.id, s)).toMap + + val messageCatalogue: MessageCatalogue = new JsonMessageCatalogue: + override val messages: Dictionary[String] = + ScenarioMain.this.messages.asInstanceOf[js.Dictionary[String]] + + val scenarioRoute: Route[Scenario.Id, String] = + Route.onlyFragment[Scenario.Id, String]( + identity[String], + identity[String], + pattern = root / prefix / "index.html" withFragment fragment[String] + ) + + given router: Router[Scenario.Id] = Router[Scenario.Id]( + routes = List(scenarioRoute), + identity[String], + identity[String], + identity[String], + routeFallback = _ => scenarios.head.id + )( + windowEvents.onPopState, + unsafeWindowOwner + ) + + def main(args: Array[String]): Unit = + given MessageCatalogue = messageCatalogue + + given ComponentContext with + val messages: MessageCatalogue = messageCatalogue + val style: StyleGuide = StyleGuide.default + + def container: HtmlElement = + div( + cls("h-full"), + div( + cls( + "fixed inset-y-0 z-50 flex w-72 flex-col" + ), + div( + cls( + "flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4" + ), + nav( + cls("flex flex-1 flex-col"), + ul( + role("list"), + cls("flex flex-1 flex-col gap-y-7"), + children <-- router.$currentPage.map(id => + scenarios.map(s => + li( + a( + href(s"#${s.id}"), + cls( + "group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold" + ), + if s.id == id then cls("bg-gray-50 text-indigo-600") + else + cls( + "text-gray-700 hover:text-indigo-600 hover:bg-gray-50" + ) + , + s.label + ) + ) + ) + ) + ) + ) + ) + ), + com.raquo.laminar.api.L.main( + cls("h-full pl-72"), + div( + cls( + "h-full max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8" + ), + child <-- router.$currentPage.map(scenarioMap(_).element) + ) + ) + ) + + val appContainer = dom.document.querySelector("#app") + render(appContainer, container) diff --git a/ui/jvm/src/main/scala/works/iterative/data/table/XSSFWriter.scala b/ui/jvm/src/main/scala/works/iterative/data/table/XSSFWriter.scala new file mode 100644 index 0000000..912e74e --- /dev/null +++ b/ui/jvm/src/main/scala/works/iterative/data/table/XSSFWriter.scala @@ -0,0 +1,195 @@ +package works.iterative.data.table + +import zio.* +import org.apache.poi.xssf.usermodel.XSSFRow +import org.apache.poi.ss.usermodel.CellStyle +import java.time.LocalDate +import org.apache.poi.xssf.usermodel.XSSFSheet +import org.apache.poi.xssf.usermodel.XSSFWorkbook +import org.apache.poi.xssf.usermodel.XSSFCellStyle +import org.apache.poi.ss.usermodel.IndexedColors +import org.apache.poi.ss.usermodel.FillPatternType +import org.apache.poi.ss.usermodel.BorderStyle +import org.apache.poi.xssf.usermodel.XSSFCell +import org.apache.poi.ss.usermodel.Hyperlink +import org.apache.poi.common.usermodel.HyperlinkType + +case class ExternalLink( + name: String, + href: String +) + +trait CellWriter[T]: + def write(cell: XSSFCell, value: T): Unit + +object CellWriter: + given CellWriter[String] with + def write(cell: XSSFCell, value: String) = cell.setCellValue(value) + + given CellWriter[Int] with + def write(cell: XSSFCell, value: Int) = cell.setCellValue(value) + + given CellWriter[LocalDate] with + def write(cell: XSSFCell, value: LocalDate) = cell.setCellValue(value) + + given CellWriter[ExternalLink] with + def write(cell: XSSFCell, value: ExternalLink) = + cell.setCellValue(value.name) + cell.setHyperlink( + cell.getSheet.getWorkbook.getCreationHelper.createHyperlink( + HyperlinkType.URL + ) + ) + cell.getHyperlink.setAddress(value.href) + + given optionalCellSetter[T](using CellWriter[T]): CellWriter[Option[T]] with + def write(cell: XSSFCell, value: Option[T]) = + value.foreach(summon[CellWriter[T]].write(cell, _)) + +case class ColumnSpec[T, U: CellWriter]( + id: String, + name: String, + get: T => U, + width: Int = 20 +): + def set(cell: XSSFCell, row: T) = + summon[CellWriter[U]].write(cell, get(row)) + +trait TableRowWriter[T]: + def writeHeaderCells(row: XSSFRow)(using TableCellStyles): UIO[Unit] + def writeDataCells(row: XSSFRow, value: T)(using TableCellStyles): UIO[Unit] + def columnSizes: List[Int] + +class ColumnSpecsTableRowWriter[T](specs: List[ColumnSpec[T, _]]) + extends TableRowWriter[T]: + override def writeHeaderCells(row: XSSFRow)(using styles: TableCellStyles) = + val headers = specs.map(_.name) + ZIO.succeed { + headers.zipWithIndex.foreach { case (header, index) => + val cell = row.createCell(index) + cell.setCellValue(header) + styles + .byName(TableCellStyles.HeaderStyleName) + .foreach(cell.setCellStyle) + } + } + + override def writeDataCells( + row: XSSFRow, + value: T + )(using styles: TableCellStyles) = + ZIO.succeed { + specs.zipWithIndex.map { case (spec, i) => + val cell = row.createCell(i) + spec.set(cell, value) + styles.byName(spec.id).foreach(cell.setCellStyle) + } + } + + override def columnSizes: List[Int] = + specs.map(_.width).toList + +type TableCellStyleDef = XSSFWorkbook => XSSFCellStyle + +trait TableCellStyles: + def byName(name: String): Option[CellStyle] + +object TableCellStyles: + val HeaderStyleName = "_header" + + val defaultHeaderStyle: TableCellStyleDef = (wb: XSSFWorkbook) => { + val headerStyle = wb.createCellStyle() + val font = wb.createFont() + font.setBold(true) + headerStyle.setFont(font) + + // set background color + headerStyle.setFillForegroundColor( + IndexedColors.GREY_25_PERCENT.getIndex() + ) + headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND) + + // set border style + headerStyle.setBorderTop(BorderStyle.MEDIUM) + headerStyle.setBorderBottom(BorderStyle.MEDIUM) + headerStyle.setBorderLeft(BorderStyle.MEDIUM) + headerStyle.setBorderRight(BorderStyle.MEDIUM) + headerStyle + } + + val dateStyle: TableCellStyleDef = (wb: XSSFWorkbook) => { + val dateCellStyle = wb.createCellStyle() + dateCellStyle.setDataFormat( + wb + .getCreationHelper() + .createDataFormat() + .getFormat("dd.MM.yyyy") + ) + dateCellStyle + } + + val wrapStyle: TableCellStyleDef = (wb: XSSFWorkbook) => { + // set cell style to shrink font size and truncate text + val wrapCellStyle = wb.createCellStyle() + wrapCellStyle.setWrapText(true) + wrapCellStyle + } + + def make(): UIO[TableCellStyles] = + ZIO.succeed( + new TableCellStyles: + override def byName(name: String): Option[CellStyle] = + None + ) + + def make( + wb: XSSFWorkbook, + styles: List[(String, TableCellStyleDef)] + ): UIO[TableCellStyles] = + ZIO + .collectAll(styles.map { case (name, styleDef) => + for style <- ZIO.succeed(styleDef(wb)) + yield name -> style + }) + .map { styles => + new TableCellStyles: + override def byName(name: String): Option[CellStyle] = + styles.toMap.get(name) + } + +class TableWorkbook private (workbook: XSSFWorkbook, styles: TableCellStyles): + def writeSheet[T](name: String, data: Iterable[T])(using + writer: TableRowWriter[T] + ): UIO[Unit] = + for + sheet <- ZIO.succeed(workbook.createSheet(name)) + headerRow <- ZIO.succeed(sheet.createRow(0)) + _ <- writer.writeHeaderCells(headerRow)(using styles) + _ <- ZIO.foreach(data.zipWithIndex) { case (value, index) => + val row = sheet.createRow(index + 1) + writer.writeDataCells(row, value)(using styles) + } + _ <- ZIO.foreach(writer.columnSizes.zipWithIndex) { case (size, index) => + ZIO.succeed(sheet.setColumnWidth(index, size * 256)) + } + yield () + + def toArray: UIO[Array[Byte]] = ZIO.succeed { + val out = new java.io.ByteArrayOutputStream() + workbook.write(out) + out.close() + out.toByteArray + } + +object TableWorkbook: + def make(): UIO[TableWorkbook] = + for + wb <- ZIO.succeed(XSSFWorkbook()) + styles <- TableCellStyles.make() + yield TableWorkbook(wb, styles) + + def make(styles: List[(String, TableCellStyleDef)]): UIO[TableWorkbook] = + for + wb <- ZIO.succeed(XSSFWorkbook()) + styles <- TableCellStyles.make(wb, styles) + yield TableWorkbook(wb, styles) diff --git a/ui/shared/src/main/scala/works/README.md b/ui/shared/src/main/scala/works/README.md new file mode 100644 index 0000000..e6d131d --- /dev/null +++ b/ui/shared/src/main/scala/works/README.md @@ -0,0 +1 @@ +This folder is intended as a place to quickly prototype features that should end up in the shared library. \ No newline at end of file diff --git a/ui/shared/src/main/scala/works/iterative/ui/Module.scala b/ui/shared/src/main/scala/works/iterative/ui/Module.scala new file mode 100644 index 0000000..6a44b4a --- /dev/null +++ b/ui/shared/src/main/scala/works/iterative/ui/Module.scala @@ -0,0 +1,10 @@ +package works.iterative.ui + +trait Module[Model, Action, Effect]: + // Define initial model and effect + def init: (Model, Option[Effect]) + // Define how to handle actions to build new model and run effects + def handle(action: Action, model: Model): (Model, Option[Effect]) + // Optionally define how to handle failures. + // To be used by implementations to allow module to display error messages. + def handleFailure: PartialFunction[Throwable, Option[Action]] diff --git a/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala b/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala new file mode 100644 index 0000000..875c7a8 --- /dev/null +++ b/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala @@ -0,0 +1,6 @@ +package works.iterative.ui + +import zio.stream.* + +trait ZIOEffectHandler[Env, Effect, Action]: + def handle(e: Effect): ZStream[Env, Throwable, Action] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala new file mode 100644 index 0000000..d2e0b4d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/Bins.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag + +case class Bin[Source, +Value]( + label: String, + description: Option[String | HtmlElement], + color: ColorDef, + valueOf: Source => Value +): + def map[NewValue]( + f: Bin[Source, Value] => Source => NewValue + ): Bin[Source, NewValue] = + copy(valueOf = f(this)) + +case class Bins[T, U](bins: Seq[Bin[T, U]]): + def apply(v: T): Seq[U] = bins.map(_.valueOf(v)) + def map[A](r: Bin[T, U] => T => A): Bins[T, A] = + copy(bins = bins.map(_.map(r))) + + def renderSeparated( + r: Bin[T, U] => T => HtmlElement, + separator: => HtmlElement = span(" / "), + container: HtmlTag[org.scalajs.dom.html.Element] = span + )(v: T): HtmlElement = + container(interleave(map(r)(v), separator)) + + def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala new file mode 100644 index 0000000..a47f5ba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala @@ -0,0 +1,45 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait ButtonComponentsModule: + def buttons: ButtonComponents + + trait ButtonComponents: + def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement + +trait DefaultButtonComponentsModule(using ctx: ComponentContext) + extends ButtonComponentsModule: + + override val buttons = new ButtonComponents: + + private inline def srHelp(id: String): Modifier[HtmlElement] = + ctx.messages + .opt(s"form.button.${id}.screenReaderHelp") + .map(sr => span(cls := "sr-only", sr)) + + override def inlineButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srHelp(id), + icon + ) + + override def iconButton(id: String, icon: SvgElement)( + mods: Modifier[HtmlElement]* + ): HtmlElement = + button( + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srHelp(id) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala new file mode 100644 index 0000000..15169f3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/DashboardComponentsModule.scala @@ -0,0 +1,191 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import works.iterative.ui.components.tailwind.experimental.ColorDef +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom.html.UList +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind + +trait DashboardComponentsModule: + def dashboard: DashboardComponents + + // Only methods that create HtmlElements + trait DashboardComponents(using ComponentContext): + def section(label: String, children: Modifier[HtmlElement]): Div + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li + def cardInitials( + initials: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div + def count(label: String, value: Int, color: ColorDef): Span + def counts(children: HtmlElement*): Span + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] + +trait DefaultDashboardComponentsModule(using ComponentContext) + extends DashboardComponentsModule: + override val dashboard: DashboardComponents = new DashboardComponents: + def section(label: String, children: Modifier[HtmlElement]): Div = + div( + cls("mt-3"), + h2( + cls("text-gray-500 text-xs font-medium uppercase tracking-wide"), + label + ), + children + ) + + def cardList(children: Modifier[HtmlElement]): ReactiveHtmlElement[UList] = + ul( + cls( + "mt-3 grid gap-5 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" + ), + children + ) + + def card( + id: String, + label: String, + initials: Div, + children: Modifier[HtmlElement] + ): Li = + li( + div( + cls("col-span-1 flex shadow-sm rounded-md"), + initials, + 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"), + div( + cls("text-gray-900 font-medium hover:text-gray-600 truncate"), + label + ), + p(cls("text-gray-500"), children) + ) + ) + ) + ) + + def cardInitials( + id: String, + color: Signal[ColorKind], + children: Modifier[HtmlElement] + ): Div = + div( + cls( + "flex-shrink-0 flex flex-col items-center justify-center w-16 text-white text-sm font-medium rounded-l-md" + ), + color.map(_(600).bg), + div(id), + children + ) + + def count(label: String, value: Int, color: ColorDef): Span = + span(color.text, title(label), s"${value}") + + def counts(children: HtmlElement*): Span = + span(interleave(children, span(" / "))) + + def table( + headers: List[String], + sections: Seq[ReactiveHtmlElement[org.scalajs.dom.html.TableRow]] + ): Div = + div( + cls("flex flex-col mt-2"), + div( + cls( + "align-middle min-w-full shadow sm:rounded-lg" + ), + L.table( + cls("min-w-full border-separate border-spacing-0"), + styleAttr("border-spacing: 0"), // Tailwind somehow doesn't work + thead( + tr( + cls("border-t border-gray-200"), + headers.map(h => + th( + cls( + "sticky top-0 z-10 px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + h + ) + ) + ) + ), + tbody(cls("bg-white divide-y divide-gray-100"), sections) + ) + ) + ) + + def tableSectionHeader( + label: Modifier[HtmlElement], + cs: Int, + mods: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + td( + cls("bg-gray-50 border-gray-200 border-t border-b"), + cls( + "sticky top-14 z-10 bg-gray-50 px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider" + ), + colSpan(cs), + label + ), + mods + ) + + def tableRow( + label: String, + mods: Modifier[HtmlElement], + children: HtmlElement* + ): ReactiveHtmlElement[org.scalajs.dom.html.TableRow] = + tr( + mods, + td( + cls( + "px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900 truncate" + ), + label + ), + children.map(e => + td( + cls( + "whitespace-nowrap px-2 py-2 text-sm text-gray-500" + ), + e + ) + ) + ) + + private def interleave( + a: Seq[HtmlElement], + separator: => HtmlElement + ): Seq[HtmlElement] = + if a.size < 2 then a + else a.init.flatMap(n => Seq(n, separator)) :+ a.last diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala new file mode 100644 index 0000000..4cff836 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/EffectHandler.scala @@ -0,0 +1,56 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +import zio.* +import com.raquo.airstream.core.Observer +import scala.annotation.implicitNotFound + +trait EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] + +object EffectHandler: + given zioEffectHandler[Env, E, A](using + Runtime[Env] + ): Conversion[ZIOEffectHandler[Env, E, A], EffectHandler[E, A]] with + def apply(h: ZIOEffectHandler[Env, E, A]): EffectHandler[E, A] = + LaminarZIOEffectHandler(h) + + def loggingHandler[E, A](name: String)( + underlying: EffectHandler[E, A] + ): EffectHandler[E, A] = + new EffectHandler[E, A]: + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + underlying( + effects.debugWithName(s"$name effects").debugLog(), + actions.debugWithName(s"$name actions").debugLog() + ) + +class LaminarZIOEffectHandler[Env, E, A](handler: ZIOEffectHandler[Env, E, A])( + using runtime: Runtime[Env] +) extends EffectHandler[E, A]: + + def apply( + effects: EventStream[E], + actions: Observer[A] + ): Modifier[HtmlElement] = + onMountCallback(ctx => + effects.foreach { effect => + Unsafe.unsafe { implicit unsafe => + runtime.unsafe + .runToFuture( + handler.handle(effect).either.runForeach { + case Right(a) => ZIO.succeed(actions.onNext(a)) + case Left(e) => ZIO.succeed(actions.onError(e)) + } + ) + } + }(ctx.owner) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala new file mode 100644 index 0000000..df42132 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala @@ -0,0 +1,97 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.raquo.laminar.keys.ReactiveProp +import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent +import org.scalajs.dom.html +import com.raquo.laminar.modifiers.KeyUpdater + +trait FormComponentsModule extends LocalDateSelectModule: + def forms: FormComponents + + trait FormComponents: + def form(mods: Modifier[HtmlElement]*): HtmlElement + + def searchField(id: String, placeholderText: Option[String] = None)( + mods: Modifier[HtmlElement]* + ): HtmlElement + + def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement + +trait DefaultFormComponentsModule(using ctx: ComponentContext) + extends FormComponentsModule: + self: IconsModule => + override val forms = new FormComponents: + + override def form( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + override def searchField( + id: String, + placeholderText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "flex-1 min-w-0", + label( + forId := id, + cls := "sr-only", + "Hledat" + ), + div( + cls := "relative rounded-md shadow-sm", + div( + cls := "absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none", + icons.`search-solid`() + ), + input( + tpe := "search", + name := "search", + idAttr := id, + cls := "focus:ring-pink-500 focus:border-pink-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md", + placeholderText + .orElse( + ctx.messages + .opt( + s"forms.search.${id}.placeholder", + s"form.search.placeholder" + ) + ) + .map(placeholder(_)), + mods + ) + ) + ) + + override def renderLocalDateSelect( + id: String, + labelText: Option[String], + placeholderText: Option[String], + mods: LocalDateSelect => Modifier[HtmlElement] + ): HtmlElement = + div( + labelText, + input( + idAttr(id), + name(id), + autoComplete("date"), + tpe("date"), + cls( + "ml-2 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md" + ), + placeholderText.map(placeholder(_)), + mods(localDateSelect) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala new file mode 100644 index 0000000..32fda8a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/IconsModule.scala @@ -0,0 +1,109 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.CustomAttrs + +trait IconsModule: + def icons: Icons + + trait Icons: + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement + def close(mods: Modifier[SvgElement]*): SvgElement + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement + def `document-chart-bar-outline`(mods: Modifier[SvgElement]*): SvgElement + +trait DefaultIconsModule(using ComponentContext) extends IconsModule: + override val icons: Icons = new Icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("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" + ) + ) + ) + + override def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + override def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + 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") + ) + ) + + override def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala new file mode 100644 index 0000000..f7bacb1 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LaminarComponent.scala @@ -0,0 +1,35 @@ +package works.iterative.ui +package components.laminar + +import com.raquo.laminar.api.L.{*, given} + +abstract class LaminarComponent[M, A, E]( + effectHandler: EffectHandler[E, A] +) extends Module[M, A, E]: + def render(m: Signal[M], actions: Observer[A]): HtmlElement + + val element: HtmlElement = + val actions = new EventBus[A] + + val zero @ (_, effect) = init + + val initialEffect$ = (effect match + case Some(e) => EventStream.fromValue(e) + case _ => EventStream.empty + ) + + val actions$ = actions.events.recover(handleFailure) + + val processor$ = actions$.foldLeft(zero) { case ((m, _), a) => + handle(a, m) + } + + val nextEffects$ = processor$.changes.collect { case (_, Some(e)) => e } + + val model$ = processor$.map(_._1) + + val effect$ = EventStream.merge(initialEffect$, nextEffects$) + + render(model$, actions.writer).amend( + effectHandler(effect$, actions.writer) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala new file mode 100644 index 0000000..98e31b9 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ListComponentsModule.scala @@ -0,0 +1,114 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.experimental.ColorKind +import works.iterative.ui.components.tailwind.laminar.LaminarExtensions.given +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html.Paragraph +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.laminar.nodes.TextNode + +trait ListComponentsModule: + + def list: ListComponents + + trait ListComponents(using ComponentContext): + def label(text: String, color: ColorKind): ReactiveHtmlElement[Paragraph] + def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None + ): Li + def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] + def listSection(header: String, list: HtmlElement): Div + def navigation(sections: Modifier[HtmlElement]): HtmlElement + +trait DefaultListComponentsModule(using ComponentContext) + extends ListComponentsModule: + override val list: ListComponents = new ListComponents: + override def label( + text: String, + color: ColorKind + ): ReactiveHtmlElement[Paragraph] = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + text + ) + + override def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None + ): Li = + li( + cls("group"), + div( + cls( + "bg-white relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" + ), + avatar.map(a => + div( + cls("flex-shrink-0"), + div( + cls( + "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" + ), + a + ) + ) + ), + div( + cls("flex-1 min-w-0"), + p( + cls("text-sm font-medium text-gray-900"), + title, + span(cls("float-right"), right) + ), + subtitle.map(st => + p( + cls("text-sm text-gray-500 truncate"), + st + ) + ) + ) + ) + ) + + override def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = + ul( + cls("relative z-0 divide-y divide-gray-200"), + role("list"), + children + ) + + override def listSection( + header: String, + list: HtmlElement + ): Div = + div( + cls("relative"), + div( + 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 + ), + list + ) + + override def navigation(sections: Modifier[HtmlElement]): HtmlElement = + nav( + cls("flex-1 min-h-0 overflow-y-auto"), + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala new file mode 100644 index 0000000..31d860d --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LocalDateSelect.scala @@ -0,0 +1,72 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.raquo.laminar.keys.ReactiveProp +import com.raquo.domtypes.jsdom.defs.events.TypedTargetEvent +import org.scalajs.dom.html +import com.raquo.laminar.modifiers.KeyUpdater + +trait LocalDateSelectModule: + val localDateSelect: LocalDateSelect = new LocalDateSelect + + class LocalDateSelect: + import LocalDateSelect.* + + def valueUpdater( + signal: Signal[Option[LocalDate]] + ): KeyUpdater.PropUpdater[String, String] = + L.value <-- signal.map(_.map(formatDate).getOrElse("")) + + // Does not work in `controlled` + // Laminar refuses the custom prop, requries its own `value` or `checked` + val value: ReactiveProp[Option[LocalDate], String] = + customProp("value", OptLocalDateAsStringCodec) + + val min: ReactiveProp[LocalDate, String] = + customProp("min", LocalDateAsStringCodec) + + val max: ReactiveProp[LocalDate, String] = + customProp("max", LocalDateAsStringCodec) + + val onInput: EventProcessor[TypedTargetEvent[html.Element], LocalDate] = + L.onInput.mapToValue.setAsValue.map(parseDate).collect { case Some(d) => + d + } + + val onOptInput + : EventProcessor[TypedTargetEvent[html.Element], Option[LocalDate]] = + onInput.mapToValue.setAsValue.map(parseDate) + + object LocalDateSelect: + import java.time.format.DateTimeFormatter + import java.time.LocalDate + import com.raquo.domtypes.generic.codecs.Codec + + private val formatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd") + + private def parseDate(date: String): Option[LocalDate] = + import scala.util.Try + if date.isEmpty then None + else Try(LocalDate.parse(date, formatter)).toOption + + private def formatDate(date: LocalDate): String = + formatter.format(date) + + object LocalDateAsStringCodec extends Codec[LocalDate, String]: + override def decode(domValue: String): LocalDate = + parseDate(domValue).orNull + + override def encode(scalaValue: LocalDate): String = + formatDate(scalaValue) + + object OptLocalDateAsStringCodec extends Codec[Option[LocalDate], String]: + override def decode(domValue: String): Option[LocalDate] = + parseDate(domValue) + + override def encode(scalaValue: Option[LocalDate]): String = + scalaValue.map(formatDate).getOrElse("") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala new file mode 100644 index 0000000..3f3e7d0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/PagesModule.scala @@ -0,0 +1,71 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait PageComponentsModule: + + val page: PageComponents + + trait PageComponents: + def container( + children: Modifier[HtmlElement]* + ): HtmlElement + + def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement + + def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement] = emptyMod, + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement + + /** Visage for clickable text, like links + */ + def clickable: Modifier[HtmlElement] + +trait DefaultPageComponentsModule(using ComponentContext) + extends PageComponentsModule: + + override val page: PageComponents = new PageComponents: + override def container( + children: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8"), + children + ) + + override def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement = + div( + cls("p-8 bg-gray-100 h-full"), + header, + children + ) + + override def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + div( + cls("pb-5 border-b border-gray-200"), + div(cls("float-right"), right), + h1( + cls("text-2xl leading-6 font-medium text-gray-900"), + title + ), + subtitle.map( + p( + cls("text-sm font-medium text-gray-500"), + _ + ) + ) + ) + + override def clickable: Modifier[HtmlElement] = + cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") 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 index 6cda48b..f23f0e8 100644 --- 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 @@ -9,7 +9,7 @@ enum DisplayClass: case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` + `inline-table`, `table-caption` object ShowUpFrom: inline def apply( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/experimental/Color.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/experimental/Color.scala new file mode 100644 index 0000000..45261a7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/experimental/Color.scala @@ -0,0 +1,162 @@ +package works.iterative.ui.components.tailwind.experimental + +import scala.util.NotGiven +import com.raquo.domtypes.generic.Modifier +import org.scalajs.dom.SVGElement + +opaque type ColorWeight = String + +extension (c: ColorWeight) def value: String = c + +object ColorWeight: + inline given int50: Conversion[50, ColorWeight] with + inline def apply(i: 50) = "50" + inline given int100: Conversion[100, ColorWeight] with + inline def apply(i: 100) = "100" + inline given int200: Conversion[200, ColorWeight] with + inline def apply(i: 200) = "200" + inline given int300: Conversion[300, ColorWeight] with + inline def apply(i: 300) = "300" + inline given int400: Conversion[400, ColorWeight] with + inline def apply(i: 400) = "400" + inline given int500: Conversion[500, ColorWeight] with + inline def apply(i: 500) = "500" + inline given int600: Conversion[600, ColorWeight] with + inline def apply(i: 600) = "600" + inline given int700: Conversion[700, ColorWeight] with + inline def apply(i: 700) = "700" + inline given int800: Conversion[800, ColorWeight] with + inline def apply(i: 800) = "800" + inline given int900: Conversion[900, ColorWeight] with + inline def apply(i: 900) = "900" + inline given int950: Conversion[950, ColorWeight] with + inline def apply(i: 950) = "950" + +enum ColorArea(val name: String): + case bg extends ColorArea("bg") + case text extends ColorArea("text") + case decoration extends ColorArea("decoration") + case border extends ColorArea("border") + case outline extends ColorArea("outline") + case divide extends ColorArea("divide") + case ring extends ColorArea("ring") + case ringOffset extends ColorArea("ring-offset") + case shadow extends ColorArea("shadow") + case accent extends ColorArea("accent") + +sealed abstract class ColorKind private (val name: String): + def apply(weight: ColorWeight): ColorDef = + ColorDef.WeightedColorDef(this, weight) + +object ColorKind: + trait Unweighted: + self: ColorKind => + override def apply(weight: ColorWeight): ColorDef = + ColorDef.UnweightedColorDef(self) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case object current extends ColorKind("current") with Unweighted + case object inherit extends ColorKind("inherit") with Unweighted + // Not present in for all methods + case object transp extends ColorKind("transparent") with Unweighted + // Seen in accent, not preset otherwise + case object auto extends ColorKind("auto") with Unweighted + // Black and white do not have weight + case object black extends ColorKind("black") with Unweighted + case object white extends ColorKind("white") with Unweighted + case object slate extends ColorKind("slate") + case object gray extends ColorKind("gray") + case object zinc extends ColorKind("zinc") + case object neutral extends ColorKind("neutral") + case object stone extends ColorKind("stone") + case object red extends ColorKind("red") + case object orange extends ColorKind("orange") + case object amber extends ColorKind("amber") + case object yellow extends ColorKind("yellow") + case object lime extends ColorKind("lime") + case object green extends ColorKind("green") + case object emerald extends ColorKind("emerald") + case object teal extends ColorKind("teal") + case object cyan extends ColorKind("cyan") + case object sky extends ColorKind("sky") + case object blue extends ColorKind("blue") + case object indigo extends ColorKind("indigo") + case object violet extends ColorKind("violet") + case object purple extends ColorKind("purple") + case object fuchsia extends ColorKind("fuchsia") + case object pink extends ColorKind("pink") + case object rose extends ColorKind("rose") + +sealed trait ColorDef: + def toCSS: String + + def bg = Color(ColorArea.bg, this) + def text = Color(ColorArea.text, this) + def decoration = Color(ColorArea.decoration, this) + def border = Color(ColorArea.border, this) + def outline = Color(ColorArea.outline, this) + def divide = Color(ColorArea.divide, this) + def ring = Color(ColorArea.ring, this) + def ringOffset = Color(ColorArea.ringOffset, this) + def shadow = Color(ColorArea.shadow, this) + def accent = Color(ColorArea.accent, this) + +// TODO: create a macro that will output the tailwind class as string directly during compilation, where possible. +object ColorDef: + case class WeightedColorDef( + kind: ColorKind, + weight: ColorWeight + ) extends ColorDef: + override def toCSS: String = s"${kind.name}-${weight.value}" + + case class UnweightedColorDef( + kind: ColorKind + ) extends ColorDef: + override def toCSS: String = kind.name + + // TODO: check that the kind is valid unweighted kind + // that means current, inherit, auto, transparent, black, white + // tried using implicit evidence, but the type inference for enumerations + // tends to generalize to the enum, instead of the real type + def apply[T <: ColorKind](kind: T)(using + ev: T <:< ColorKind.Unweighted + ): ColorDef = + UnweightedColorDef(kind) + def apply(kind: ColorKind, weight: ColorWeight): ColorDef = + WeightedColorDef(kind, weight) + +case class Color(area: ColorArea, color: ColorDef): + def toCSS: String = s"${area.name}-${color.toCSS}" + +object Color: + import ColorDef.given + + def current = ColorDef(ColorKind.current) + def inherit = ColorDef(ColorKind.inherit) + def transp = ColorDef(ColorKind.transp) + def auto = ColorDef(ColorKind.auto) + def black = ColorDef(ColorKind.black) + def white = ColorDef(ColorKind.white) + def slate(weight: ColorWeight) = ColorDef(ColorKind.slate, weight) + def gray(weight: ColorWeight) = ColorDef(ColorKind.gray, weight) + def zinc(weight: ColorWeight) = ColorDef(ColorKind.zinc, weight) + def neutral(weight: ColorWeight) = ColorDef(ColorKind.neutral, weight) + def stone(weight: ColorWeight) = ColorDef(ColorKind.stone, weight) + def red(weight: ColorWeight) = ColorDef(ColorKind.red, weight) + def orange(weight: ColorWeight) = ColorDef(ColorKind.orange, weight) + def amber(weight: ColorWeight) = ColorDef(ColorKind.amber, weight) + def yellow(weight: ColorWeight) = ColorDef(ColorKind.yellow, weight) + def lime(weight: ColorWeight) = ColorDef(ColorKind.lime, weight) + def green(weight: ColorWeight) = ColorDef(ColorKind.green, weight) + def emerald(weight: ColorWeight) = ColorDef(ColorKind.emerald, weight) + def teal(weight: ColorWeight) = ColorDef(ColorKind.teal, weight) + def cyan(weight: ColorWeight) = ColorDef(ColorKind.cyan, weight) + def sky(weight: ColorWeight) = ColorDef(ColorKind.sky, weight) + def blue(weight: ColorWeight) = ColorDef(ColorKind.blue, weight) + def indigo(weight: ColorWeight) = ColorDef(ColorKind.indigo, weight) + def violet(weight: ColorWeight) = ColorDef(ColorKind.violet, weight) + def purple(weight: ColorWeight) = ColorDef(ColorKind.purple, weight) + def fuchsia(weight: ColorWeight) = ColorDef(ColorKind.fuchsia, weight) + def pink(weight: ColorWeight) = ColorDef(ColorKind.pink, weight) + def rose(weight: ColorWeight) = ColorDef(ColorKind.rose, weight) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala new file mode 100644 index 0000000..7777c7a --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/laminar/LaminarExtensions.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package laminar + +import com.raquo.laminar.api.L.{*, given} + +object LaminarExtensions: + given colorToCSS: Conversion[experimental.Color, Modifier[HtmlElement]] with + def apply(c: experimental.Color) = cls(c.toCSS) + + given colorToSVGCSS: Conversion[experimental.Color, Modifier[SvgElement]] with + def apply(c: experimental.Color) = svg.cls(c.toCSS) + + given colorSignalToCSS + : Conversion[Signal[experimental.Color], Modifier[HtmlElement]] with + def apply(c: Signal[experimental.Color]) = cls <-- c.map(_.toCSS) + + given colorSignalToSVGCSS + : Conversion[Signal[experimental.Color], Modifier[SvgElement]] with + def apply(c: Signal[experimental.Color]) = svg.cls <-- c.map(_.toCSS) diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala new file mode 100644 index 0000000..6e005af --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/Scenario.scala @@ -0,0 +1,78 @@ +package works.iterative.ui +package scenarios + +import com.raquo.laminar.api.L + +import scala.scalajs.js.annotation.{JSExportTopLevel, JSImport} +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom +import org.scalajs.dom.html + +import scala.scalajs.js +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.StyleGuide +import works.iterative.ui.components.tailwind.experimental.Color +import works.iterative.ui.components.tailwind.experimental.ColorWeight + +object Scenario: + type Id = String + +trait Scenario: + trait ScenarioContext: + def events: Observer[Any] + + def id: Scenario.Id + + def label: String + + def element(using ComponentContext): HtmlElement + +trait ScenarioExample: + def title: String + def element(using ComponentContext): HtmlElement + +object ScenarioExample: + def apply( + t: String, + elem: ComponentContext ?=> HtmlElement + ): ScenarioExample = + new ScenarioExample: + override val title: String = t + override def element(using ComponentContext): HtmlElement = elem + +trait ScenarioExamples: + self: Scenario => + + protected def examples(using + ScenarioContext, + ComponentContext + ): List[ScenarioExample] + + override def element(using ComponentContext): HtmlElement = + val eventBus: EventBus[Any] = EventBus[Any]() + + given sc: ScenarioContext = new ScenarioContext: + def events: Observer[Any] = eventBus.writer + + div( + cls("flex flex-col space-y-5"), + eventBus.events --> { e => + org.scalajs.dom.console.log(s"action: ${e.toString}") + }, + examples.map(se => example(se.title, se.element)) + ) + + def example(t: String, c: HtmlElement): Div = + div( + cls("bg-white overflow-hidden shadow rounded-lg"), + div( + cls("px-4 py-5 sm:p-6"), + h3( + cls("text-lg leading-6 font-medium text-gray-900 border-b"), + t + ), + div(cls("px-5 py-5"), c) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala new file mode 100644 index 0000000..90e446c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala @@ -0,0 +1,107 @@ +package works.iterative.ui.scenarios + +import scala.scalajs.js.annotation.{JSExportTopLevel, JSImport} +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom + +import scala.scalajs.js +import works.iterative.ui.JsonMessageCatalogue +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.tailwind.ComponentContext +import works.iterative.ui.components.tailwind.StyleGuide +import ui.components.tailwind.TailwindSupport +import com.raquo.waypoint.* + +import scala.scalajs.js.Dictionary + +trait ScenarioMain( + prefix: String, + scenarios: List[Scenario], + messages: js.Any, + css: js.Any +) extends TailwindSupport: + + val scenarioMap: Map[Scenario.Id, Scenario] = + scenarios.map(s => (s.id, s)).toMap + + val messageCatalogue: MessageCatalogue = new JsonMessageCatalogue: + override val messages: Dictionary[String] = + ScenarioMain.this.messages.asInstanceOf[js.Dictionary[String]] + + val scenarioRoute: Route[Scenario.Id, String] = + Route.onlyFragment[Scenario.Id, String]( + identity[String], + identity[String], + pattern = root / prefix / "index.html" withFragment fragment[String] + ) + + given router: Router[Scenario.Id] = Router[Scenario.Id]( + routes = List(scenarioRoute), + identity[String], + identity[String], + identity[String], + routeFallback = _ => scenarios.head.id + )( + windowEvents.onPopState, + unsafeWindowOwner + ) + + def main(args: Array[String]): Unit = + given MessageCatalogue = messageCatalogue + + given ComponentContext with + val messages: MessageCatalogue = messageCatalogue + val style: StyleGuide = StyleGuide.default + + def container: HtmlElement = + div( + cls("h-full"), + div( + cls( + "fixed inset-y-0 z-50 flex w-72 flex-col" + ), + div( + cls( + "flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4" + ), + nav( + cls("flex flex-1 flex-col"), + ul( + role("list"), + cls("flex flex-1 flex-col gap-y-7"), + children <-- router.$currentPage.map(id => + scenarios.map(s => + li( + a( + href(s"#${s.id}"), + cls( + "group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold" + ), + if s.id == id then cls("bg-gray-50 text-indigo-600") + else + cls( + "text-gray-700 hover:text-indigo-600 hover:bg-gray-50" + ) + , + s.label + ) + ) + ) + ) + ) + ) + ) + ), + com.raquo.laminar.api.L.main( + cls("h-full pl-72"), + div( + cls( + "h-full max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8" + ), + child <-- router.$currentPage.map(scenarioMap(_).element) + ) + ) + ) + + val appContainer = dom.document.querySelector("#app") + render(appContainer, container) diff --git a/ui/jvm/src/main/scala/works/iterative/data/table/XSSFWriter.scala b/ui/jvm/src/main/scala/works/iterative/data/table/XSSFWriter.scala new file mode 100644 index 0000000..912e74e --- /dev/null +++ b/ui/jvm/src/main/scala/works/iterative/data/table/XSSFWriter.scala @@ -0,0 +1,195 @@ +package works.iterative.data.table + +import zio.* +import org.apache.poi.xssf.usermodel.XSSFRow +import org.apache.poi.ss.usermodel.CellStyle +import java.time.LocalDate +import org.apache.poi.xssf.usermodel.XSSFSheet +import org.apache.poi.xssf.usermodel.XSSFWorkbook +import org.apache.poi.xssf.usermodel.XSSFCellStyle +import org.apache.poi.ss.usermodel.IndexedColors +import org.apache.poi.ss.usermodel.FillPatternType +import org.apache.poi.ss.usermodel.BorderStyle +import org.apache.poi.xssf.usermodel.XSSFCell +import org.apache.poi.ss.usermodel.Hyperlink +import org.apache.poi.common.usermodel.HyperlinkType + +case class ExternalLink( + name: String, + href: String +) + +trait CellWriter[T]: + def write(cell: XSSFCell, value: T): Unit + +object CellWriter: + given CellWriter[String] with + def write(cell: XSSFCell, value: String) = cell.setCellValue(value) + + given CellWriter[Int] with + def write(cell: XSSFCell, value: Int) = cell.setCellValue(value) + + given CellWriter[LocalDate] with + def write(cell: XSSFCell, value: LocalDate) = cell.setCellValue(value) + + given CellWriter[ExternalLink] with + def write(cell: XSSFCell, value: ExternalLink) = + cell.setCellValue(value.name) + cell.setHyperlink( + cell.getSheet.getWorkbook.getCreationHelper.createHyperlink( + HyperlinkType.URL + ) + ) + cell.getHyperlink.setAddress(value.href) + + given optionalCellSetter[T](using CellWriter[T]): CellWriter[Option[T]] with + def write(cell: XSSFCell, value: Option[T]) = + value.foreach(summon[CellWriter[T]].write(cell, _)) + +case class ColumnSpec[T, U: CellWriter]( + id: String, + name: String, + get: T => U, + width: Int = 20 +): + def set(cell: XSSFCell, row: T) = + summon[CellWriter[U]].write(cell, get(row)) + +trait TableRowWriter[T]: + def writeHeaderCells(row: XSSFRow)(using TableCellStyles): UIO[Unit] + def writeDataCells(row: XSSFRow, value: T)(using TableCellStyles): UIO[Unit] + def columnSizes: List[Int] + +class ColumnSpecsTableRowWriter[T](specs: List[ColumnSpec[T, _]]) + extends TableRowWriter[T]: + override def writeHeaderCells(row: XSSFRow)(using styles: TableCellStyles) = + val headers = specs.map(_.name) + ZIO.succeed { + headers.zipWithIndex.foreach { case (header, index) => + val cell = row.createCell(index) + cell.setCellValue(header) + styles + .byName(TableCellStyles.HeaderStyleName) + .foreach(cell.setCellStyle) + } + } + + override def writeDataCells( + row: XSSFRow, + value: T + )(using styles: TableCellStyles) = + ZIO.succeed { + specs.zipWithIndex.map { case (spec, i) => + val cell = row.createCell(i) + spec.set(cell, value) + styles.byName(spec.id).foreach(cell.setCellStyle) + } + } + + override def columnSizes: List[Int] = + specs.map(_.width).toList + +type TableCellStyleDef = XSSFWorkbook => XSSFCellStyle + +trait TableCellStyles: + def byName(name: String): Option[CellStyle] + +object TableCellStyles: + val HeaderStyleName = "_header" + + val defaultHeaderStyle: TableCellStyleDef = (wb: XSSFWorkbook) => { + val headerStyle = wb.createCellStyle() + val font = wb.createFont() + font.setBold(true) + headerStyle.setFont(font) + + // set background color + headerStyle.setFillForegroundColor( + IndexedColors.GREY_25_PERCENT.getIndex() + ) + headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND) + + // set border style + headerStyle.setBorderTop(BorderStyle.MEDIUM) + headerStyle.setBorderBottom(BorderStyle.MEDIUM) + headerStyle.setBorderLeft(BorderStyle.MEDIUM) + headerStyle.setBorderRight(BorderStyle.MEDIUM) + headerStyle + } + + val dateStyle: TableCellStyleDef = (wb: XSSFWorkbook) => { + val dateCellStyle = wb.createCellStyle() + dateCellStyle.setDataFormat( + wb + .getCreationHelper() + .createDataFormat() + .getFormat("dd.MM.yyyy") + ) + dateCellStyle + } + + val wrapStyle: TableCellStyleDef = (wb: XSSFWorkbook) => { + // set cell style to shrink font size and truncate text + val wrapCellStyle = wb.createCellStyle() + wrapCellStyle.setWrapText(true) + wrapCellStyle + } + + def make(): UIO[TableCellStyles] = + ZIO.succeed( + new TableCellStyles: + override def byName(name: String): Option[CellStyle] = + None + ) + + def make( + wb: XSSFWorkbook, + styles: List[(String, TableCellStyleDef)] + ): UIO[TableCellStyles] = + ZIO + .collectAll(styles.map { case (name, styleDef) => + for style <- ZIO.succeed(styleDef(wb)) + yield name -> style + }) + .map { styles => + new TableCellStyles: + override def byName(name: String): Option[CellStyle] = + styles.toMap.get(name) + } + +class TableWorkbook private (workbook: XSSFWorkbook, styles: TableCellStyles): + def writeSheet[T](name: String, data: Iterable[T])(using + writer: TableRowWriter[T] + ): UIO[Unit] = + for + sheet <- ZIO.succeed(workbook.createSheet(name)) + headerRow <- ZIO.succeed(sheet.createRow(0)) + _ <- writer.writeHeaderCells(headerRow)(using styles) + _ <- ZIO.foreach(data.zipWithIndex) { case (value, index) => + val row = sheet.createRow(index + 1) + writer.writeDataCells(row, value)(using styles) + } + _ <- ZIO.foreach(writer.columnSizes.zipWithIndex) { case (size, index) => + ZIO.succeed(sheet.setColumnWidth(index, size * 256)) + } + yield () + + def toArray: UIO[Array[Byte]] = ZIO.succeed { + val out = new java.io.ByteArrayOutputStream() + workbook.write(out) + out.close() + out.toByteArray + } + +object TableWorkbook: + def make(): UIO[TableWorkbook] = + for + wb <- ZIO.succeed(XSSFWorkbook()) + styles <- TableCellStyles.make() + yield TableWorkbook(wb, styles) + + def make(styles: List[(String, TableCellStyleDef)]): UIO[TableWorkbook] = + for + wb <- ZIO.succeed(XSSFWorkbook()) + styles <- TableCellStyles.make(wb, styles) + yield TableWorkbook(wb, styles) diff --git a/ui/shared/src/main/scala/works/README.md b/ui/shared/src/main/scala/works/README.md new file mode 100644 index 0000000..e6d131d --- /dev/null +++ b/ui/shared/src/main/scala/works/README.md @@ -0,0 +1 @@ +This folder is intended as a place to quickly prototype features that should end up in the shared library. \ No newline at end of file diff --git a/ui/shared/src/main/scala/works/iterative/ui/Module.scala b/ui/shared/src/main/scala/works/iterative/ui/Module.scala new file mode 100644 index 0000000..6a44b4a --- /dev/null +++ b/ui/shared/src/main/scala/works/iterative/ui/Module.scala @@ -0,0 +1,10 @@ +package works.iterative.ui + +trait Module[Model, Action, Effect]: + // Define initial model and effect + def init: (Model, Option[Effect]) + // Define how to handle actions to build new model and run effects + def handle(action: Action, model: Model): (Model, Option[Effect]) + // Optionally define how to handle failures. + // To be used by implementations to allow module to display error messages. + def handleFailure: PartialFunction[Throwable, Option[Action]] diff --git a/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala b/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala new file mode 100644 index 0000000..875c7a8 --- /dev/null +++ b/ui/shared/src/main/scala/works/iterative/ui/ZIOEffectHandler.scala @@ -0,0 +1,6 @@ +package works.iterative.ui + +import zio.stream.* + +trait ZIOEffectHandler[Env, Effect, Action]: + def handle(e: Effect): ZStream[Env, Throwable, Action] diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala b/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala new file mode 100644 index 0000000..29287ba --- /dev/null +++ b/ui/shared/src/main/scala/works/iterative/ui/model/Computable.scala @@ -0,0 +1,12 @@ +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]