diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala index dbb0c24..8de85e4 100644 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -11,36 +11,33 @@ object UploadButton: class Component(upload: Observer[FileList]) extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") ) - ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala index dbb0c24..8de85e4 100644 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -11,36 +11,33 @@ object UploadButton: class Component(upload: Observer[FileList]) extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") ) - ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) ) + ) diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala index 623862d..7b7351f 100644 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -1,9 +1,16 @@ package works.iterative.ui.components.tailwind import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement import org.scalajs.dom +import com.raquo.airstream.core.EventStream -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala index dbb0c24..8de85e4 100644 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -11,36 +11,33 @@ object UploadButton: class Component(upload: Observer[FileList]) extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") ) - ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) ) + ) diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala index 623862d..7b7351f 100644 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -1,9 +1,16 @@ package works.iterative.ui.components.tailwind import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement import org.scalajs.dom +import com.raquo.airstream.core.EventStream -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..e1ead5f --- /dev/null +++ b/ui/components/src/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.airstream.core.Observer + +trait ComponentContext: + def eventBus: Observer[ui.model.AppEvent] diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala index dbb0c24..8de85e4 100644 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -11,36 +11,33 @@ object UploadButton: class Component(upload: Observer[FileList]) extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") ) - ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) ) + ) diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala index 623862d..7b7351f 100644 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -1,9 +1,16 @@ package works.iterative.ui.components.tailwind import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement import org.scalajs.dom +import com.raquo.airstream.core.EventStream -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..e1ead5f --- /dev/null +++ b/ui/components/src/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.airstream.core.Observer + +trait ComponentContext: + def eventBus: Observer[ui.model.AppEvent] diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala index 56a0708..5cbedf0 100644 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -7,6 +7,8 @@ import com.raquo.laminar.builders.SvgBuilders import com.raquo.laminar.keys.ReactiveSvgAttr import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.model.Icon +import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: @@ -312,4 +314,12 @@ ) end solid + + class IconComponent(extraClasses: String) + extends SvgComponent[org.scalajs.dom.svg.Element, Icon]: + def render(i: Icon): SvgElement = + import Icon.* + i match + case Bell => outline.bell(extraClasses) + end Icons diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala index dbb0c24..8de85e4 100644 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -11,36 +11,33 @@ object UploadButton: class Component(upload: Observer[FileList]) extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") ) - ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) ) + ) diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala index 623862d..7b7351f 100644 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -1,9 +1,16 @@ package works.iterative.ui.components.tailwind import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement import org.scalajs.dom +import com.raquo.airstream.core.EventStream -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..e1ead5f --- /dev/null +++ b/ui/components/src/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.airstream.core.Observer + +trait ComponentContext: + def eventBus: Observer[ui.model.AppEvent] diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala index 56a0708..5cbedf0 100644 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -7,6 +7,8 @@ import com.raquo.laminar.builders.SvgBuilders import com.raquo.laminar.keys.ReactiveSvgAttr import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.model.Icon +import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: @@ -312,4 +314,12 @@ ) end solid + + class IconComponent(extraClasses: String) + extends SvgComponent[org.scalajs.dom.svg.Element, Icon]: + def render(i: Icon): SvgElement = + import Icon.* + i match + case Bell => outline.bell(extraClasses) + end Icons diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 61ed625..e0e18d6 100644 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -39,41 +39,40 @@ given leftAlignedInCardComponent[A](using HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] with - extension (d: LeftAlignedInCard[A]) - def element: HtmlElement = + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body ) ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) ) - else None - ) + ) + else None + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala index dbb0c24..8de85e4 100644 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -11,36 +11,33 @@ object UploadButton: class Component(upload: Observer[FileList]) extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") ) - ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) ) + ) diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala index 623862d..7b7351f 100644 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -1,9 +1,16 @@ package works.iterative.ui.components.tailwind import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement import org.scalajs.dom +import com.raquo.airstream.core.EventStream -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..e1ead5f --- /dev/null +++ b/ui/components/src/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.airstream.core.Observer + +trait ComponentContext: + def eventBus: Observer[ui.model.AppEvent] diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala index 56a0708..5cbedf0 100644 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -7,6 +7,8 @@ import com.raquo.laminar.builders.SvgBuilders import com.raquo.laminar.keys.ReactiveSvgAttr import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.model.Icon +import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: @@ -312,4 +314,12 @@ ) end solid + + class IconComponent(extraClasses: String) + extends SvgComponent[org.scalajs.dom.svg.Element, Icon]: + def render(i: Icon): SvgElement = + import Icon.* + i match + case Bell => outline.bell(extraClasses) + end Icons diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 61ed625..e0e18d6 100644 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -39,41 +39,40 @@ given leftAlignedInCardComponent[A](using HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] with - extension (d: LeftAlignedInCard[A]) - def element: HtmlElement = + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body ) ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) ) - else None - ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala index 49af7c0..232b3cd 100644 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -18,20 +18,18 @@ object ActionButtons: class Component[A](actions: Observer[A]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - extension (v: ActionButtons[A]) - def element: Div = - div( - cls("flex justify-end"), - v.actions.zipWithIndex.map { - case (ActionButton(title, action), idx) => - button( - tpe("button"), - cls(if idx == 0 then "" else "ml-3"), - cls( - "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - title, - onClick.mapTo(action) --> actions - ) - } - ) + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala index dbb0c24..8de85e4 100644 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -11,36 +11,33 @@ object UploadButton: class Component(upload: Observer[FileList]) extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") ) - ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) ) + ) diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala index 623862d..7b7351f 100644 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -1,9 +1,16 @@ package works.iterative.ui.components.tailwind import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement import org.scalajs.dom +import com.raquo.airstream.core.EventStream -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..e1ead5f --- /dev/null +++ b/ui/components/src/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.airstream.core.Observer + +trait ComponentContext: + def eventBus: Observer[ui.model.AppEvent] diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala index 56a0708..5cbedf0 100644 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -7,6 +7,8 @@ import com.raquo.laminar.builders.SvgBuilders import com.raquo.laminar.keys.ReactiveSvgAttr import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.model.Icon +import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: @@ -312,4 +314,12 @@ ) end solid + + class IconComponent(extraClasses: String) + extends SvgComponent[org.scalajs.dom.svg.Element, Icon]: + def render(i: Icon): SvgElement = + import Icon.* + i match + case Bell => outline.bell(extraClasses) + end Icons diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 61ed625..e0e18d6 100644 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -39,41 +39,40 @@ given leftAlignedInCardComponent[A](using HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] with - extension (d: LeftAlignedInCard[A]) - def element: HtmlElement = + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body ) ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) ) - else None - ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala index 49af7c0..232b3cd 100644 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -18,20 +18,18 @@ object ActionButtons: class Component[A](actions: Observer[A]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - extension (v: ActionButtons[A]) - def element: Div = - div( - cls("flex justify-end"), - v.actions.zipWithIndex.map { - case (ActionButton(title, action), idx) => - button( - tpe("button"), - cls(if idx == 0 then "" else "ml-3"), - cls( - "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - title, - onClick.mapTo(action) --> actions - ) - } - ) + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala index 54a7769..54d74f1 100644 --- a/ui/components/src/ui/components/tailwind/list/ListRow.scala +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -20,40 +20,40 @@ object ListRow: - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with - extension (r: ListRow) - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", div( - cls := "px-4 py-4 sm:px-6 items-center flex", + cls := "min-w-0 flex-1 pr-4", div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title ), div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight + cls := "ml-2 flex-shrink-0 flex", + r.topRight ) ), - r.farRight - ) + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight ) + ) - def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = - val c = content - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala index dbb0c24..8de85e4 100644 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -11,36 +11,33 @@ object UploadButton: class Component(upload: Observer[FileList]) extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") ) - ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) ) + ) diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala index 623862d..7b7351f 100644 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -1,9 +1,16 @@ package works.iterative.ui.components.tailwind import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement import org.scalajs.dom +import com.raquo.airstream.core.EventStream -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..e1ead5f --- /dev/null +++ b/ui/components/src/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.airstream.core.Observer + +trait ComponentContext: + def eventBus: Observer[ui.model.AppEvent] diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala index 56a0708..5cbedf0 100644 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -7,6 +7,8 @@ import com.raquo.laminar.builders.SvgBuilders import com.raquo.laminar.keys.ReactiveSvgAttr import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.model.Icon +import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: @@ -312,4 +314,12 @@ ) end solid + + class IconComponent(extraClasses: String) + extends SvgComponent[org.scalajs.dom.svg.Element, Icon]: + def render(i: Icon): SvgElement = + import Icon.* + i match + case Bell => outline.bell(extraClasses) + end Icons diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 61ed625..e0e18d6 100644 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -39,41 +39,40 @@ given leftAlignedInCardComponent[A](using HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] with - extension (d: LeftAlignedInCard[A]) - def element: HtmlElement = + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body ) ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) ) - else None - ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala index 49af7c0..232b3cd 100644 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -18,20 +18,18 @@ object ActionButtons: class Component[A](actions: Observer[A]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - extension (v: ActionButtons[A]) - def element: Div = - div( - cls("flex justify-end"), - v.actions.zipWithIndex.map { - case (ActionButton(title, action), idx) => - button( - tpe("button"), - cls(if idx == 0 then "" else "ml-3"), - cls( - "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - title, - onClick.mapTo(action) --> actions - ) - } - ) + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala index 54a7769..54d74f1 100644 --- a/ui/components/src/ui/components/tailwind/list/ListRow.scala +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -20,40 +20,40 @@ object ListRow: - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with - extension (r: ListRow) - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", div( - cls := "px-4 py-4 sm:px-6 items-center flex", + cls := "min-w-0 flex-1 pr-4", div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title ), div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight + cls := "ml-2 flex-shrink-0 flex", + r.topRight ) ), - r.farRight - ) + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight ) + ) - def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = - val c = content - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/ui/components/tailwind/lists/stacked_lists/WithRightJustifiedSecondColumn.scala b/ui/components/src/ui/components/tailwind/lists/stacked_lists/WithRightJustifiedSecondColumn.scala new file mode 100644 index 0000000..456edb0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/lists/stacked_lists/WithRightJustifiedSecondColumn.scala @@ -0,0 +1,118 @@ +package works.iterative +package ui +package components.tailwind +package lists.stacked_lists + +import com.raquo.laminar.api.L.{*, given} + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html.{UList, LI, Paragraph} +import works.iterative.ui.model.AppEvent +import model.{ListItem, ItemList, Label, ItemProp, HighlightColor} + +object WithRightJustifiedSecondColumn: + given Icons.IconComponent("h-5 w-5") + def leftProp(i: Int): HtmlComponent[Paragraph, ItemProp] = + (prop: ItemProp) => + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0" + ), + cls( + if prop.icon.isDefined then "sm:ml-6" + else if i != 0 then "sm:ml-2" + else "" + ), + prop.icon.map(_.element), + prop.text + ) + + val rightProp: HtmlComponent[org.scalajs.dom.html.Div, ItemProp] = + (prop: ItemProp) => + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + prop.icon.map(_.element), + prop.text + ) + + given HtmlComponent[Paragraph, Label] = + (l: Label) => + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // TODO: macros + // cls(colorClass(color)), + cls := (l.color match { + case HighlightColor.Red => "text-red-800 bg-red-100" + case HighlightColor.Amber => "text-amber-800 bg-amber-100" + case HighlightColor.Green => "text-green-800 bg-green-100" + case HighlightColor.Yellow => "text-yellow-800 bg-yellow-100" + case HighlightColor.Orange => "text-orange-800 bg-orange-100" + case HighlightColor.Gray => "text-gray-800 bg-gray-100" + }), + l.text + ) + + val defaultItem: ComponentContext ?=> HtmlComponent[LI, ListItem] = + (i: ListItem) => + li( + a( + href := i.href, + onClick.preventDefault.mapTo(AppEvent.NavigateTo(i.href)) --> summon[ + ComponentContext + ].eventBus, + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + i.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + i.label.map(_.element) + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps.zipWithIndex.map { case (p, i) => + leftProp(i).render(p) + } + ), + i.rightProp.map(rightProp.render(_)) + ) + ) + // r.farRight + ) + ) + ) + + def defaultList(using + HtmlComponent[LI, ListItem] + ): ComponentContext ?=> HtmlComponent[org.scalajs.dom.html.Div, ItemList] = + (l: ItemList) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-md", + l.items.map(i => { + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + i.title + ), + ul( + role := "list", + cls := "divide-y divide-gray-200", + cls("relative"), + i.items.map(_.element) + ) + ) + }) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala index dbb0c24..8de85e4 100644 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -11,36 +11,33 @@ object UploadButton: class Component(upload: Observer[FileList]) extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") ) - ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) ) + ) diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala index 623862d..7b7351f 100644 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -1,9 +1,16 @@ package works.iterative.ui.components.tailwind import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement import org.scalajs.dom +import com.raquo.airstream.core.EventStream -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..e1ead5f --- /dev/null +++ b/ui/components/src/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.airstream.core.Observer + +trait ComponentContext: + def eventBus: Observer[ui.model.AppEvent] diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala index 56a0708..5cbedf0 100644 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -7,6 +7,8 @@ import com.raquo.laminar.builders.SvgBuilders import com.raquo.laminar.keys.ReactiveSvgAttr import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.model.Icon +import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: @@ -312,4 +314,12 @@ ) end solid + + class IconComponent(extraClasses: String) + extends SvgComponent[org.scalajs.dom.svg.Element, Icon]: + def render(i: Icon): SvgElement = + import Icon.* + i match + case Bell => outline.bell(extraClasses) + end Icons diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 61ed625..e0e18d6 100644 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -39,41 +39,40 @@ given leftAlignedInCardComponent[A](using HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] with - extension (d: LeftAlignedInCard[A]) - def element: HtmlElement = + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body ) ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) ) - else None - ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala index 49af7c0..232b3cd 100644 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -18,20 +18,18 @@ object ActionButtons: class Component[A](actions: Observer[A]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - extension (v: ActionButtons[A]) - def element: Div = - div( - cls("flex justify-end"), - v.actions.zipWithIndex.map { - case (ActionButton(title, action), idx) => - button( - tpe("button"), - cls(if idx == 0 then "" else "ml-3"), - cls( - "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - title, - onClick.mapTo(action) --> actions - ) - } - ) + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala index 54a7769..54d74f1 100644 --- a/ui/components/src/ui/components/tailwind/list/ListRow.scala +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -20,40 +20,40 @@ object ListRow: - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with - extension (r: ListRow) - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", div( - cls := "px-4 py-4 sm:px-6 items-center flex", + cls := "min-w-0 flex-1 pr-4", div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title ), div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight + cls := "ml-2 flex-shrink-0 flex", + r.topRight ) ), - r.farRight - ) + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight ) + ) - def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = - val c = content - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/ui/components/tailwind/lists/stacked_lists/WithRightJustifiedSecondColumn.scala b/ui/components/src/ui/components/tailwind/lists/stacked_lists/WithRightJustifiedSecondColumn.scala new file mode 100644 index 0000000..456edb0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/lists/stacked_lists/WithRightJustifiedSecondColumn.scala @@ -0,0 +1,118 @@ +package works.iterative +package ui +package components.tailwind +package lists.stacked_lists + +import com.raquo.laminar.api.L.{*, given} + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html.{UList, LI, Paragraph} +import works.iterative.ui.model.AppEvent +import model.{ListItem, ItemList, Label, ItemProp, HighlightColor} + +object WithRightJustifiedSecondColumn: + given Icons.IconComponent("h-5 w-5") + def leftProp(i: Int): HtmlComponent[Paragraph, ItemProp] = + (prop: ItemProp) => + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0" + ), + cls( + if prop.icon.isDefined then "sm:ml-6" + else if i != 0 then "sm:ml-2" + else "" + ), + prop.icon.map(_.element), + prop.text + ) + + val rightProp: HtmlComponent[org.scalajs.dom.html.Div, ItemProp] = + (prop: ItemProp) => + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + prop.icon.map(_.element), + prop.text + ) + + given HtmlComponent[Paragraph, Label] = + (l: Label) => + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // TODO: macros + // cls(colorClass(color)), + cls := (l.color match { + case HighlightColor.Red => "text-red-800 bg-red-100" + case HighlightColor.Amber => "text-amber-800 bg-amber-100" + case HighlightColor.Green => "text-green-800 bg-green-100" + case HighlightColor.Yellow => "text-yellow-800 bg-yellow-100" + case HighlightColor.Orange => "text-orange-800 bg-orange-100" + case HighlightColor.Gray => "text-gray-800 bg-gray-100" + }), + l.text + ) + + val defaultItem: ComponentContext ?=> HtmlComponent[LI, ListItem] = + (i: ListItem) => + li( + a( + href := i.href, + onClick.preventDefault.mapTo(AppEvent.NavigateTo(i.href)) --> summon[ + ComponentContext + ].eventBus, + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + i.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + i.label.map(_.element) + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps.zipWithIndex.map { case (p, i) => + leftProp(i).render(p) + } + ), + i.rightProp.map(rightProp.render(_)) + ) + ) + // r.farRight + ) + ) + ) + + def defaultList(using + HtmlComponent[LI, ListItem] + ): ComponentContext ?=> HtmlComponent[org.scalajs.dom.html.Div, ItemList] = + (l: ItemList) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-md", + l.items.map(i => { + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + i.title + ), + ul( + role := "list", + cls := "divide-y divide-gray-200", + cls("relative"), + i.items.map(_.element) + ) + ) + }) + ) diff --git a/ui/model/src/AppEvent.scala b/ui/model/src/AppEvent.scala new file mode 100644 index 0000000..1ddc218 --- /dev/null +++ b/ui/model/src/AppEvent.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.model + +enum AppEvent: + case NavigateTo(href: String) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala index dbb0c24..8de85e4 100644 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -11,36 +11,33 @@ object UploadButton: class Component(upload: Observer[FileList]) extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") ) - ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) ) + ) diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala index 623862d..7b7351f 100644 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -1,9 +1,16 @@ package works.iterative.ui.components.tailwind import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement import org.scalajs.dom +import com.raquo.airstream.core.EventStream -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..e1ead5f --- /dev/null +++ b/ui/components/src/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.airstream.core.Observer + +trait ComponentContext: + def eventBus: Observer[ui.model.AppEvent] diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala index 56a0708..5cbedf0 100644 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -7,6 +7,8 @@ import com.raquo.laminar.builders.SvgBuilders import com.raquo.laminar.keys.ReactiveSvgAttr import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.model.Icon +import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: @@ -312,4 +314,12 @@ ) end solid + + class IconComponent(extraClasses: String) + extends SvgComponent[org.scalajs.dom.svg.Element, Icon]: + def render(i: Icon): SvgElement = + import Icon.* + i match + case Bell => outline.bell(extraClasses) + end Icons diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 61ed625..e0e18d6 100644 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -39,41 +39,40 @@ given leftAlignedInCardComponent[A](using HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] with - extension (d: LeftAlignedInCard[A]) - def element: HtmlElement = + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body ) ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) ) - else None - ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala index 49af7c0..232b3cd 100644 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -18,20 +18,18 @@ object ActionButtons: class Component[A](actions: Observer[A]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - extension (v: ActionButtons[A]) - def element: Div = - div( - cls("flex justify-end"), - v.actions.zipWithIndex.map { - case (ActionButton(title, action), idx) => - button( - tpe("button"), - cls(if idx == 0 then "" else "ml-3"), - cls( - "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - title, - onClick.mapTo(action) --> actions - ) - } - ) + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala index 54a7769..54d74f1 100644 --- a/ui/components/src/ui/components/tailwind/list/ListRow.scala +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -20,40 +20,40 @@ object ListRow: - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with - extension (r: ListRow) - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", div( - cls := "px-4 py-4 sm:px-6 items-center flex", + cls := "min-w-0 flex-1 pr-4", div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title ), div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight + cls := "ml-2 flex-shrink-0 flex", + r.topRight ) ), - r.farRight - ) + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight ) + ) - def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = - val c = content - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/ui/components/tailwind/lists/stacked_lists/WithRightJustifiedSecondColumn.scala b/ui/components/src/ui/components/tailwind/lists/stacked_lists/WithRightJustifiedSecondColumn.scala new file mode 100644 index 0000000..456edb0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/lists/stacked_lists/WithRightJustifiedSecondColumn.scala @@ -0,0 +1,118 @@ +package works.iterative +package ui +package components.tailwind +package lists.stacked_lists + +import com.raquo.laminar.api.L.{*, given} + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html.{UList, LI, Paragraph} +import works.iterative.ui.model.AppEvent +import model.{ListItem, ItemList, Label, ItemProp, HighlightColor} + +object WithRightJustifiedSecondColumn: + given Icons.IconComponent("h-5 w-5") + def leftProp(i: Int): HtmlComponent[Paragraph, ItemProp] = + (prop: ItemProp) => + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0" + ), + cls( + if prop.icon.isDefined then "sm:ml-6" + else if i != 0 then "sm:ml-2" + else "" + ), + prop.icon.map(_.element), + prop.text + ) + + val rightProp: HtmlComponent[org.scalajs.dom.html.Div, ItemProp] = + (prop: ItemProp) => + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + prop.icon.map(_.element), + prop.text + ) + + given HtmlComponent[Paragraph, Label] = + (l: Label) => + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // TODO: macros + // cls(colorClass(color)), + cls := (l.color match { + case HighlightColor.Red => "text-red-800 bg-red-100" + case HighlightColor.Amber => "text-amber-800 bg-amber-100" + case HighlightColor.Green => "text-green-800 bg-green-100" + case HighlightColor.Yellow => "text-yellow-800 bg-yellow-100" + case HighlightColor.Orange => "text-orange-800 bg-orange-100" + case HighlightColor.Gray => "text-gray-800 bg-gray-100" + }), + l.text + ) + + val defaultItem: ComponentContext ?=> HtmlComponent[LI, ListItem] = + (i: ListItem) => + li( + a( + href := i.href, + onClick.preventDefault.mapTo(AppEvent.NavigateTo(i.href)) --> summon[ + ComponentContext + ].eventBus, + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + i.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + i.label.map(_.element) + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps.zipWithIndex.map { case (p, i) => + leftProp(i).render(p) + } + ), + i.rightProp.map(rightProp.render(_)) + ) + ) + // r.farRight + ) + ) + ) + + def defaultList(using + HtmlComponent[LI, ListItem] + ): ComponentContext ?=> HtmlComponent[org.scalajs.dom.html.Div, ItemList] = + (l: ItemList) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-md", + l.items.map(i => { + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + i.title + ), + ul( + role := "list", + cls := "divide-y divide-gray-200", + cls("relative"), + i.items.map(_.element) + ) + ) + }) + ) diff --git a/ui/model/src/AppEvent.scala b/ui/model/src/AppEvent.scala new file mode 100644 index 0000000..1ddc218 --- /dev/null +++ b/ui/model/src/AppEvent.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.model + +enum AppEvent: + case NavigateTo(href: String) diff --git a/ui/model/src/HighlightColor.scala b/ui/model/src/HighlightColor.scala new file mode 100644 index 0000000..0efda68 --- /dev/null +++ b/ui/model/src/HighlightColor.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.model + +enum HighlightColor: + case Red, Green, Yellow, Amber, Orange, Gray diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala index dbb0c24..8de85e4 100644 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -11,36 +11,33 @@ object UploadButton: class Component(upload: Observer[FileList]) extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") ) - ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) ) + ) diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala index 623862d..7b7351f 100644 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -1,9 +1,16 @@ package works.iterative.ui.components.tailwind import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement import org.scalajs.dom +import com.raquo.airstream.core.EventStream -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..e1ead5f --- /dev/null +++ b/ui/components/src/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.airstream.core.Observer + +trait ComponentContext: + def eventBus: Observer[ui.model.AppEvent] diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala index 56a0708..5cbedf0 100644 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -7,6 +7,8 @@ import com.raquo.laminar.builders.SvgBuilders import com.raquo.laminar.keys.ReactiveSvgAttr import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.model.Icon +import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: @@ -312,4 +314,12 @@ ) end solid + + class IconComponent(extraClasses: String) + extends SvgComponent[org.scalajs.dom.svg.Element, Icon]: + def render(i: Icon): SvgElement = + import Icon.* + i match + case Bell => outline.bell(extraClasses) + end Icons diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 61ed625..e0e18d6 100644 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -39,41 +39,40 @@ given leftAlignedInCardComponent[A](using HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] with - extension (d: LeftAlignedInCard[A]) - def element: HtmlElement = + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body ) ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) ) - else None - ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala index 49af7c0..232b3cd 100644 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -18,20 +18,18 @@ object ActionButtons: class Component[A](actions: Observer[A]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - extension (v: ActionButtons[A]) - def element: Div = - div( - cls("flex justify-end"), - v.actions.zipWithIndex.map { - case (ActionButton(title, action), idx) => - button( - tpe("button"), - cls(if idx == 0 then "" else "ml-3"), - cls( - "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - title, - onClick.mapTo(action) --> actions - ) - } - ) + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala index 54a7769..54d74f1 100644 --- a/ui/components/src/ui/components/tailwind/list/ListRow.scala +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -20,40 +20,40 @@ object ListRow: - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with - extension (r: ListRow) - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", div( - cls := "px-4 py-4 sm:px-6 items-center flex", + cls := "min-w-0 flex-1 pr-4", div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title ), div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight + cls := "ml-2 flex-shrink-0 flex", + r.topRight ) ), - r.farRight - ) + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight ) + ) - def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = - val c = content - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/ui/components/tailwind/lists/stacked_lists/WithRightJustifiedSecondColumn.scala b/ui/components/src/ui/components/tailwind/lists/stacked_lists/WithRightJustifiedSecondColumn.scala new file mode 100644 index 0000000..456edb0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/lists/stacked_lists/WithRightJustifiedSecondColumn.scala @@ -0,0 +1,118 @@ +package works.iterative +package ui +package components.tailwind +package lists.stacked_lists + +import com.raquo.laminar.api.L.{*, given} + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html.{UList, LI, Paragraph} +import works.iterative.ui.model.AppEvent +import model.{ListItem, ItemList, Label, ItemProp, HighlightColor} + +object WithRightJustifiedSecondColumn: + given Icons.IconComponent("h-5 w-5") + def leftProp(i: Int): HtmlComponent[Paragraph, ItemProp] = + (prop: ItemProp) => + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0" + ), + cls( + if prop.icon.isDefined then "sm:ml-6" + else if i != 0 then "sm:ml-2" + else "" + ), + prop.icon.map(_.element), + prop.text + ) + + val rightProp: HtmlComponent[org.scalajs.dom.html.Div, ItemProp] = + (prop: ItemProp) => + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + prop.icon.map(_.element), + prop.text + ) + + given HtmlComponent[Paragraph, Label] = + (l: Label) => + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // TODO: macros + // cls(colorClass(color)), + cls := (l.color match { + case HighlightColor.Red => "text-red-800 bg-red-100" + case HighlightColor.Amber => "text-amber-800 bg-amber-100" + case HighlightColor.Green => "text-green-800 bg-green-100" + case HighlightColor.Yellow => "text-yellow-800 bg-yellow-100" + case HighlightColor.Orange => "text-orange-800 bg-orange-100" + case HighlightColor.Gray => "text-gray-800 bg-gray-100" + }), + l.text + ) + + val defaultItem: ComponentContext ?=> HtmlComponent[LI, ListItem] = + (i: ListItem) => + li( + a( + href := i.href, + onClick.preventDefault.mapTo(AppEvent.NavigateTo(i.href)) --> summon[ + ComponentContext + ].eventBus, + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + i.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + i.label.map(_.element) + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps.zipWithIndex.map { case (p, i) => + leftProp(i).render(p) + } + ), + i.rightProp.map(rightProp.render(_)) + ) + ) + // r.farRight + ) + ) + ) + + def defaultList(using + HtmlComponent[LI, ListItem] + ): ComponentContext ?=> HtmlComponent[org.scalajs.dom.html.Div, ItemList] = + (l: ItemList) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-md", + l.items.map(i => { + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + i.title + ), + ul( + role := "list", + cls := "divide-y divide-gray-200", + cls("relative"), + i.items.map(_.element) + ) + ) + }) + ) diff --git a/ui/model/src/AppEvent.scala b/ui/model/src/AppEvent.scala new file mode 100644 index 0000000..1ddc218 --- /dev/null +++ b/ui/model/src/AppEvent.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.model + +enum AppEvent: + case NavigateTo(href: String) diff --git a/ui/model/src/HighlightColor.scala b/ui/model/src/HighlightColor.scala new file mode 100644 index 0000000..0efda68 --- /dev/null +++ b/ui/model/src/HighlightColor.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.model + +enum HighlightColor: + case Red, Green, Yellow, Amber, Orange, Gray diff --git a/ui/model/src/Icons.scala b/ui/model/src/Icons.scala new file mode 100644 index 0000000..8735630 --- /dev/null +++ b/ui/model/src/Icons.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.model + +enum Icon: + case Bell diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala index dbb0c24..8de85e4 100644 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -11,36 +11,33 @@ object UploadButton: class Component(upload: Observer[FileList]) extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") ) - ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) ) + ) diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala index 623862d..7b7351f 100644 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -1,9 +1,16 @@ package works.iterative.ui.components.tailwind import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement import org.scalajs.dom +import com.raquo.airstream.core.EventStream -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..e1ead5f --- /dev/null +++ b/ui/components/src/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.airstream.core.Observer + +trait ComponentContext: + def eventBus: Observer[ui.model.AppEvent] diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala index 56a0708..5cbedf0 100644 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -7,6 +7,8 @@ import com.raquo.laminar.builders.SvgBuilders import com.raquo.laminar.keys.ReactiveSvgAttr import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.model.Icon +import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: @@ -312,4 +314,12 @@ ) end solid + + class IconComponent(extraClasses: String) + extends SvgComponent[org.scalajs.dom.svg.Element, Icon]: + def render(i: Icon): SvgElement = + import Icon.* + i match + case Bell => outline.bell(extraClasses) + end Icons diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 61ed625..e0e18d6 100644 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -39,41 +39,40 @@ given leftAlignedInCardComponent[A](using HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] with - extension (d: LeftAlignedInCard[A]) - def element: HtmlElement = + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body ) ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) ) - else None - ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala index 49af7c0..232b3cd 100644 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -18,20 +18,18 @@ object ActionButtons: class Component[A](actions: Observer[A]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - extension (v: ActionButtons[A]) - def element: Div = - div( - cls("flex justify-end"), - v.actions.zipWithIndex.map { - case (ActionButton(title, action), idx) => - button( - tpe("button"), - cls(if idx == 0 then "" else "ml-3"), - cls( - "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - title, - onClick.mapTo(action) --> actions - ) - } - ) + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala index 54a7769..54d74f1 100644 --- a/ui/components/src/ui/components/tailwind/list/ListRow.scala +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -20,40 +20,40 @@ object ListRow: - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with - extension (r: ListRow) - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", div( - cls := "px-4 py-4 sm:px-6 items-center flex", + cls := "min-w-0 flex-1 pr-4", div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title ), div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight + cls := "ml-2 flex-shrink-0 flex", + r.topRight ) ), - r.farRight - ) + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight ) + ) - def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = - val c = content - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/ui/components/tailwind/lists/stacked_lists/WithRightJustifiedSecondColumn.scala b/ui/components/src/ui/components/tailwind/lists/stacked_lists/WithRightJustifiedSecondColumn.scala new file mode 100644 index 0000000..456edb0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/lists/stacked_lists/WithRightJustifiedSecondColumn.scala @@ -0,0 +1,118 @@ +package works.iterative +package ui +package components.tailwind +package lists.stacked_lists + +import com.raquo.laminar.api.L.{*, given} + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html.{UList, LI, Paragraph} +import works.iterative.ui.model.AppEvent +import model.{ListItem, ItemList, Label, ItemProp, HighlightColor} + +object WithRightJustifiedSecondColumn: + given Icons.IconComponent("h-5 w-5") + def leftProp(i: Int): HtmlComponent[Paragraph, ItemProp] = + (prop: ItemProp) => + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0" + ), + cls( + if prop.icon.isDefined then "sm:ml-6" + else if i != 0 then "sm:ml-2" + else "" + ), + prop.icon.map(_.element), + prop.text + ) + + val rightProp: HtmlComponent[org.scalajs.dom.html.Div, ItemProp] = + (prop: ItemProp) => + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + prop.icon.map(_.element), + prop.text + ) + + given HtmlComponent[Paragraph, Label] = + (l: Label) => + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // TODO: macros + // cls(colorClass(color)), + cls := (l.color match { + case HighlightColor.Red => "text-red-800 bg-red-100" + case HighlightColor.Amber => "text-amber-800 bg-amber-100" + case HighlightColor.Green => "text-green-800 bg-green-100" + case HighlightColor.Yellow => "text-yellow-800 bg-yellow-100" + case HighlightColor.Orange => "text-orange-800 bg-orange-100" + case HighlightColor.Gray => "text-gray-800 bg-gray-100" + }), + l.text + ) + + val defaultItem: ComponentContext ?=> HtmlComponent[LI, ListItem] = + (i: ListItem) => + li( + a( + href := i.href, + onClick.preventDefault.mapTo(AppEvent.NavigateTo(i.href)) --> summon[ + ComponentContext + ].eventBus, + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + i.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + i.label.map(_.element) + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps.zipWithIndex.map { case (p, i) => + leftProp(i).render(p) + } + ), + i.rightProp.map(rightProp.render(_)) + ) + ) + // r.farRight + ) + ) + ) + + def defaultList(using + HtmlComponent[LI, ListItem] + ): ComponentContext ?=> HtmlComponent[org.scalajs.dom.html.Div, ItemList] = + (l: ItemList) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-md", + l.items.map(i => { + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + i.title + ), + ul( + role := "list", + cls := "divide-y divide-gray-200", + cls("relative"), + i.items.map(_.element) + ) + ) + }) + ) diff --git a/ui/model/src/AppEvent.scala b/ui/model/src/AppEvent.scala new file mode 100644 index 0000000..1ddc218 --- /dev/null +++ b/ui/model/src/AppEvent.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.model + +enum AppEvent: + case NavigateTo(href: String) diff --git a/ui/model/src/HighlightColor.scala b/ui/model/src/HighlightColor.scala new file mode 100644 index 0000000..0efda68 --- /dev/null +++ b/ui/model/src/HighlightColor.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.model + +enum HighlightColor: + case Red, Green, Yellow, Amber, Orange, Gray diff --git a/ui/model/src/Icons.scala b/ui/model/src/Icons.scala new file mode 100644 index 0000000..8735630 --- /dev/null +++ b/ui/model/src/Icons.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.model + +enum Icon: + case Bell diff --git a/ui/model/src/ItemList.scala b/ui/model/src/ItemList.scala new file mode 100644 index 0000000..7c56254 --- /dev/null +++ b/ui/model/src/ItemList.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.model + +case class ItemList( + items: List[ListSection] +) + +case class ItemProp( + text: String, + icon: Option[Icon] = None +) + +case class ListSection( + title: String, + items: List[ListItem] +) + +case class ListItem( + title: String, + href: String, + label: Option[Label] = None, + leftProps: List[ItemProp] = Nil, + rightProp: Option[ItemProp] = None, + categories: List[String] = Nil +) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala index dbb0c24..8de85e4 100644 --- a/ui/components/src/services/files/components/tailwind/UploadButton.scala +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -11,36 +11,33 @@ object UploadButton: class Component(upload: Observer[FileList]) extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload + override def render(u: UploadButton) = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") ) - ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => onInput.mapTo(thisNode.ref.files) --> upload) ) ) + ) diff --git a/ui/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala index 623862d..7b7351f 100644 --- a/ui/components/src/ui/components/tailwind/Component.scala +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -1,9 +1,16 @@ package works.iterative.ui.components.tailwind import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.nodes.ReactiveSvgElement import org.scalajs.dom +import com.raquo.airstream.core.EventStream -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] +trait HtmlComponent[Ref <: dom.html.Element, -A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] = render(a) + def render(a: A): ReactiveHtmlElement[Ref] -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] +type BaseHtmlComponent[-A] = HtmlComponent[dom.html.Element, A] + +trait SvgComponent[Ref <: dom.svg.Element, -A]: + extension (a: A) def element: ReactiveSvgElement[Ref] = render(a) + def render(a: A): ReactiveSvgElement[Ref] diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala new file mode 100644 index 0000000..e1ead5f --- /dev/null +++ b/ui/components/src/ui/components/tailwind/ComponentContext.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import com.raquo.airstream.core.Observer + +trait ComponentContext: + def eventBus: Observer[ui.model.AppEvent] diff --git a/ui/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala index 56a0708..5cbedf0 100644 --- a/ui/components/src/ui/components/tailwind/Icons.scala +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -7,6 +7,8 @@ import com.raquo.laminar.builders.SvgBuilders import com.raquo.laminar.keys.ReactiveSvgAttr import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.model.Icon +import com.raquo.laminar.nodes.ReactiveSvgElement object Icons: object aria: @@ -312,4 +314,12 @@ ) end solid + + class IconComponent(extraClasses: String) + extends SvgComponent[org.scalajs.dom.svg.Element, Icon]: + def render(i: Icon): SvgElement = + import Icon.* + i match + case Bell => outline.bell(extraClasses) + end Icons diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index 61ed625..e0e18d6 100644 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -39,41 +39,40 @@ given leftAlignedInCardComponent[A](using HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] with - extension (d: LeftAlignedInCard[A]) - def element: HtmlElement = + ): BaseHtmlComponent[LeftAlignedInCard[A]] = + (d: LeftAlignedInCard[A]) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body ) ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) ) - else None - ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala index 49af7c0..232b3cd 100644 --- a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -18,20 +18,18 @@ object ActionButtons: class Component[A](actions: Observer[A]) extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - extension (v: ActionButtons[A]) - def element: Div = - div( - cls("flex justify-end"), - v.actions.zipWithIndex.map { - case (ActionButton(title, action), idx) => - button( - tpe("button"), - cls(if idx == 0 then "" else "ml-3"), - cls( - "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - title, - onClick.mapTo(action) --> actions - ) - } - ) + override def render(v: ActionButtons[A]) = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala index 54a7769..54d74f1 100644 --- a/ui/components/src/ui/components/tailwind/list/ListRow.scala +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -20,40 +20,40 @@ object ListRow: - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with - extension (r: ListRow) - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] = (r: ListRow) => { + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", div( - cls := "px-4 py-4 sm:px-6 items-center flex", + cls := "min-w-0 flex-1 pr-4", div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + r.title ), div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight + cls := "ml-2 flex-shrink-0 flex", + r.topRight ) ), - r.farRight - ) + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight ) + ) - def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = - val c = content - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) + val c = content + + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) + } diff --git a/ui/components/src/ui/components/tailwind/lists/stacked_lists/WithRightJustifiedSecondColumn.scala b/ui/components/src/ui/components/tailwind/lists/stacked_lists/WithRightJustifiedSecondColumn.scala new file mode 100644 index 0000000..456edb0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/lists/stacked_lists/WithRightJustifiedSecondColumn.scala @@ -0,0 +1,118 @@ +package works.iterative +package ui +package components.tailwind +package lists.stacked_lists + +import com.raquo.laminar.api.L.{*, given} + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html.{UList, LI, Paragraph} +import works.iterative.ui.model.AppEvent +import model.{ListItem, ItemList, Label, ItemProp, HighlightColor} + +object WithRightJustifiedSecondColumn: + given Icons.IconComponent("h-5 w-5") + def leftProp(i: Int): HtmlComponent[Paragraph, ItemProp] = + (prop: ItemProp) => + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0" + ), + cls( + if prop.icon.isDefined then "sm:ml-6" + else if i != 0 then "sm:ml-2" + else "" + ), + prop.icon.map(_.element), + prop.text + ) + + val rightProp: HtmlComponent[org.scalajs.dom.html.Div, ItemProp] = + (prop: ItemProp) => + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + prop.icon.map(_.element), + prop.text + ) + + given HtmlComponent[Paragraph, Label] = + (l: Label) => + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + // TODO: macros + // cls(colorClass(color)), + cls := (l.color match { + case HighlightColor.Red => "text-red-800 bg-red-100" + case HighlightColor.Amber => "text-amber-800 bg-amber-100" + case HighlightColor.Green => "text-green-800 bg-green-100" + case HighlightColor.Yellow => "text-yellow-800 bg-yellow-100" + case HighlightColor.Orange => "text-orange-800 bg-orange-100" + case HighlightColor.Gray => "text-gray-800 bg-gray-100" + }), + l.text + ) + + val defaultItem: ComponentContext ?=> HtmlComponent[LI, ListItem] = + (i: ListItem) => + li( + a( + href := i.href, + onClick.preventDefault.mapTo(AppEvent.NavigateTo(i.href)) --> summon[ + ComponentContext + ].eventBus, + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + i.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + i.label.map(_.element) + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps.zipWithIndex.map { case (p, i) => + leftProp(i).render(p) + } + ), + i.rightProp.map(rightProp.render(_)) + ) + ) + // r.farRight + ) + ) + ) + + def defaultList(using + HtmlComponent[LI, ListItem] + ): ComponentContext ?=> HtmlComponent[org.scalajs.dom.html.Div, ItemList] = + (l: ItemList) => + div( + cls := "bg-white shadow overflow-hidden sm:rounded-md", + l.items.map(i => { + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + i.title + ), + ul( + role := "list", + cls := "divide-y divide-gray-200", + cls("relative"), + i.items.map(_.element) + ) + ) + }) + ) diff --git a/ui/model/src/AppEvent.scala b/ui/model/src/AppEvent.scala new file mode 100644 index 0000000..1ddc218 --- /dev/null +++ b/ui/model/src/AppEvent.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.model + +enum AppEvent: + case NavigateTo(href: String) diff --git a/ui/model/src/HighlightColor.scala b/ui/model/src/HighlightColor.scala new file mode 100644 index 0000000..0efda68 --- /dev/null +++ b/ui/model/src/HighlightColor.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.model + +enum HighlightColor: + case Red, Green, Yellow, Amber, Orange, Gray diff --git a/ui/model/src/Icons.scala b/ui/model/src/Icons.scala new file mode 100644 index 0000000..8735630 --- /dev/null +++ b/ui/model/src/Icons.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.model + +enum Icon: + case Bell diff --git a/ui/model/src/ItemList.scala b/ui/model/src/ItemList.scala new file mode 100644 index 0000000..7c56254 --- /dev/null +++ b/ui/model/src/ItemList.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.model + +case class ItemList( + items: List[ListSection] +) + +case class ItemProp( + text: String, + icon: Option[Icon] = None +) + +case class ListSection( + title: String, + items: List[ListItem] +) + +case class ListItem( + title: String, + href: String, + label: Option[Label] = None, + leftProps: List[ItemProp] = Nil, + rightProp: Option[ItemProp] = None, + categories: List[String] = Nil +) diff --git a/ui/model/src/Label.scala b/ui/model/src/Label.scala new file mode 100644 index 0000000..19741fd --- /dev/null +++ b/ui/model/src/Label.scala @@ -0,0 +1,3 @@ +package works.iterative.ui.model + +case class Label(color: HighlightColor, text: String)