diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala new file mode 100644 index 0000000..d2bd069 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala @@ -0,0 +1,160 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind +import works.iterative.ui.components.laminar.tailwind.color.ColorWeight + +trait AlertComponentsModule: + self: IconsModule => + object alerts: + // TODO: use context functions and builder pattern to build the alerts + def alert( + message: HtmlMod, + icon: SvgElement, + color: ColorKind, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + div( + cls := "rounded-md p-4", + color(50).bg, + div( + cls := "flex", + icon, + div( + cls := "ml-3", + title.map(t => + h3( + cls := "text-sm font-medium", + color(800).text, + t + ) + ), + div( + cls := "text-sm", + title.map(_ => cls("mt-2")), + color(700).text, + message + ), + actions.map { act => + div( + cls := "mt-4", + act(color) + ) + } + ), + mods(color) + ) + ) + + def warning( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-warning`(), + ColorKind.yellow, + title = title, + actions = actions, + mods = mods + ) + + def error( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-error`(), + ColorKind.red, + title = title, + actions = actions, + mods = mods + ) + + def success( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-success`(), + ColorKind.green, + title = title, + actions = actions, + mods = mods + ) + + def info( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-info`(), + ColorKind.blue, + title = title, + actions = actions, + mods = mods + ) + + def buttons(buttons: (ColorKind => HtmlMod)*): ColorKind => HtmlMod = + color => + div( + cls("-mx-2 -my-1.5 flex"), + buttons.map(_(color)) + ) + + def button(title: String)(mods: HtmlMod*): ColorKind => HtmlMod = color => + L.button( + tpe("button"), + cls( + "first:ml-0 ml-3 rounded-md px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(800).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + title, + mods + ) + + def closeMod(mods: HtmlMod): ColorKind => HtmlMod = color => + val closeIcon: SvgElement = + import svg.* + svg( + cls := "h-5 w-5", + viewBox := "0 0 20 20", + fill := "currentColor", + path( + d := "M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" + ) + ) + div( + cls("ml-auto pl-3"), + L.button( + cls( + "-mx-1.5 -my-1.5 inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(500).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + mods, + aria.hidden := true, + closeIcon + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala new file mode 100644 index 0000000..d2bd069 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala @@ -0,0 +1,160 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind +import works.iterative.ui.components.laminar.tailwind.color.ColorWeight + +trait AlertComponentsModule: + self: IconsModule => + object alerts: + // TODO: use context functions and builder pattern to build the alerts + def alert( + message: HtmlMod, + icon: SvgElement, + color: ColorKind, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + div( + cls := "rounded-md p-4", + color(50).bg, + div( + cls := "flex", + icon, + div( + cls := "ml-3", + title.map(t => + h3( + cls := "text-sm font-medium", + color(800).text, + t + ) + ), + div( + cls := "text-sm", + title.map(_ => cls("mt-2")), + color(700).text, + message + ), + actions.map { act => + div( + cls := "mt-4", + act(color) + ) + } + ), + mods(color) + ) + ) + + def warning( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-warning`(), + ColorKind.yellow, + title = title, + actions = actions, + mods = mods + ) + + def error( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-error`(), + ColorKind.red, + title = title, + actions = actions, + mods = mods + ) + + def success( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-success`(), + ColorKind.green, + title = title, + actions = actions, + mods = mods + ) + + def info( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-info`(), + ColorKind.blue, + title = title, + actions = actions, + mods = mods + ) + + def buttons(buttons: (ColorKind => HtmlMod)*): ColorKind => HtmlMod = + color => + div( + cls("-mx-2 -my-1.5 flex"), + buttons.map(_(color)) + ) + + def button(title: String)(mods: HtmlMod*): ColorKind => HtmlMod = color => + L.button( + tpe("button"), + cls( + "first:ml-0 ml-3 rounded-md px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(800).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + title, + mods + ) + + def closeMod(mods: HtmlMod): ColorKind => HtmlMod = color => + val closeIcon: SvgElement = + import svg.* + svg( + cls := "h-5 w-5", + viewBox := "0 0 20 20", + fill := "currentColor", + path( + d := "M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" + ) + ) + div( + cls("ml-auto pl-3"), + L.button( + cls( + "-mx-1.5 -my-1.5 inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(500).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + mods, + aria.hidden := true, + closeIcon + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala new file mode 100644 index 0000000..fa374fe --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala @@ -0,0 +1,52 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait AppShellComponentsModule: + object shell: + object stackedLight extends StackedAppShell: + override def navCls = cls("border-b border-gray-200 bg-white") + override def headerCls = emptyMod + override def pageWrapper(content: HtmlMod*): HtmlMod = + div(cls("py-10"), content) + + object stackedBranded extends StackedAppShell: + override def navCls = cls("bg-indigo-600") + override def headerCls = cls("bg-white shadown-sm") + override def pageWrapper(content: HtmlMod*): HtmlMod = content + +trait StackedAppShell: + protected def navCls: HtmlMod + protected def headerCls: HtmlMod + protected def pageWrapper(content: HtmlMod*): HtmlMod + + def apply(pageTitle: HtmlMod)(navbarItems: HtmlMod*)( + content: HtmlMod* + ): HtmlElement = + div( + cls("min-h-full"), + navTag( + navCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + div(cls("flex h-16 items-center justify-between"), navbarItems) + ) + ), + pageWrapper( + headerTag( + headerCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + h1( + cls( + "text-3xl font-bold leading-tight tracking-tight text-gray-900" + ), + pageTitle + ) + ) + ), + mainTag( + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala new file mode 100644 index 0000000..d2bd069 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala @@ -0,0 +1,160 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind +import works.iterative.ui.components.laminar.tailwind.color.ColorWeight + +trait AlertComponentsModule: + self: IconsModule => + object alerts: + // TODO: use context functions and builder pattern to build the alerts + def alert( + message: HtmlMod, + icon: SvgElement, + color: ColorKind, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + div( + cls := "rounded-md p-4", + color(50).bg, + div( + cls := "flex", + icon, + div( + cls := "ml-3", + title.map(t => + h3( + cls := "text-sm font-medium", + color(800).text, + t + ) + ), + div( + cls := "text-sm", + title.map(_ => cls("mt-2")), + color(700).text, + message + ), + actions.map { act => + div( + cls := "mt-4", + act(color) + ) + } + ), + mods(color) + ) + ) + + def warning( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-warning`(), + ColorKind.yellow, + title = title, + actions = actions, + mods = mods + ) + + def error( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-error`(), + ColorKind.red, + title = title, + actions = actions, + mods = mods + ) + + def success( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-success`(), + ColorKind.green, + title = title, + actions = actions, + mods = mods + ) + + def info( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-info`(), + ColorKind.blue, + title = title, + actions = actions, + mods = mods + ) + + def buttons(buttons: (ColorKind => HtmlMod)*): ColorKind => HtmlMod = + color => + div( + cls("-mx-2 -my-1.5 flex"), + buttons.map(_(color)) + ) + + def button(title: String)(mods: HtmlMod*): ColorKind => HtmlMod = color => + L.button( + tpe("button"), + cls( + "first:ml-0 ml-3 rounded-md px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(800).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + title, + mods + ) + + def closeMod(mods: HtmlMod): ColorKind => HtmlMod = color => + val closeIcon: SvgElement = + import svg.* + svg( + cls := "h-5 w-5", + viewBox := "0 0 20 20", + fill := "currentColor", + path( + d := "M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" + ) + ) + div( + cls("ml-auto pl-3"), + L.button( + cls( + "-mx-1.5 -my-1.5 inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(500).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + mods, + aria.hidden := true, + closeIcon + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala new file mode 100644 index 0000000..fa374fe --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala @@ -0,0 +1,52 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait AppShellComponentsModule: + object shell: + object stackedLight extends StackedAppShell: + override def navCls = cls("border-b border-gray-200 bg-white") + override def headerCls = emptyMod + override def pageWrapper(content: HtmlMod*): HtmlMod = + div(cls("py-10"), content) + + object stackedBranded extends StackedAppShell: + override def navCls = cls("bg-indigo-600") + override def headerCls = cls("bg-white shadown-sm") + override def pageWrapper(content: HtmlMod*): HtmlMod = content + +trait StackedAppShell: + protected def navCls: HtmlMod + protected def headerCls: HtmlMod + protected def pageWrapper(content: HtmlMod*): HtmlMod + + def apply(pageTitle: HtmlMod)(navbarItems: HtmlMod*)( + content: HtmlMod* + ): HtmlElement = + div( + cls("min-h-full"), + navTag( + navCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + div(cls("flex h-16 items-center justify-between"), navbarItems) + ) + ), + pageWrapper( + headerTag( + headerCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + h1( + cls( + "text-3xl font-bold leading-tight tracking-tight text-gray-900" + ), + pageTitle + ) + ) + ), + mainTag( + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala new file mode 100644 index 0000000..f6d7d7f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait BadgeComponentsModule: + + object badges: + def pill(name: String, color: ColorKind): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + name + ) + + def pill(name: String, color: Signal[ColorKind]): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + cls <-- color.map(_.apply(800).text.toCSS), + cls <-- color.map(_.apply(100).bg.toCSS), + name + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala new file mode 100644 index 0000000..d2bd069 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala @@ -0,0 +1,160 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind +import works.iterative.ui.components.laminar.tailwind.color.ColorWeight + +trait AlertComponentsModule: + self: IconsModule => + object alerts: + // TODO: use context functions and builder pattern to build the alerts + def alert( + message: HtmlMod, + icon: SvgElement, + color: ColorKind, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + div( + cls := "rounded-md p-4", + color(50).bg, + div( + cls := "flex", + icon, + div( + cls := "ml-3", + title.map(t => + h3( + cls := "text-sm font-medium", + color(800).text, + t + ) + ), + div( + cls := "text-sm", + title.map(_ => cls("mt-2")), + color(700).text, + message + ), + actions.map { act => + div( + cls := "mt-4", + act(color) + ) + } + ), + mods(color) + ) + ) + + def warning( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-warning`(), + ColorKind.yellow, + title = title, + actions = actions, + mods = mods + ) + + def error( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-error`(), + ColorKind.red, + title = title, + actions = actions, + mods = mods + ) + + def success( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-success`(), + ColorKind.green, + title = title, + actions = actions, + mods = mods + ) + + def info( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-info`(), + ColorKind.blue, + title = title, + actions = actions, + mods = mods + ) + + def buttons(buttons: (ColorKind => HtmlMod)*): ColorKind => HtmlMod = + color => + div( + cls("-mx-2 -my-1.5 flex"), + buttons.map(_(color)) + ) + + def button(title: String)(mods: HtmlMod*): ColorKind => HtmlMod = color => + L.button( + tpe("button"), + cls( + "first:ml-0 ml-3 rounded-md px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(800).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + title, + mods + ) + + def closeMod(mods: HtmlMod): ColorKind => HtmlMod = color => + val closeIcon: SvgElement = + import svg.* + svg( + cls := "h-5 w-5", + viewBox := "0 0 20 20", + fill := "currentColor", + path( + d := "M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" + ) + ) + div( + cls("ml-auto pl-3"), + L.button( + cls( + "-mx-1.5 -my-1.5 inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(500).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + mods, + aria.hidden := true, + closeIcon + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala new file mode 100644 index 0000000..fa374fe --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala @@ -0,0 +1,52 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait AppShellComponentsModule: + object shell: + object stackedLight extends StackedAppShell: + override def navCls = cls("border-b border-gray-200 bg-white") + override def headerCls = emptyMod + override def pageWrapper(content: HtmlMod*): HtmlMod = + div(cls("py-10"), content) + + object stackedBranded extends StackedAppShell: + override def navCls = cls("bg-indigo-600") + override def headerCls = cls("bg-white shadown-sm") + override def pageWrapper(content: HtmlMod*): HtmlMod = content + +trait StackedAppShell: + protected def navCls: HtmlMod + protected def headerCls: HtmlMod + protected def pageWrapper(content: HtmlMod*): HtmlMod + + def apply(pageTitle: HtmlMod)(navbarItems: HtmlMod*)( + content: HtmlMod* + ): HtmlElement = + div( + cls("min-h-full"), + navTag( + navCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + div(cls("flex h-16 items-center justify-between"), navbarItems) + ) + ), + pageWrapper( + headerTag( + headerCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + h1( + cls( + "text-3xl font-bold leading-tight tracking-tight text-gray-900" + ), + pageTitle + ) + ) + ), + mainTag( + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala new file mode 100644 index 0000000..f6d7d7f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait BadgeComponentsModule: + + object badges: + def pill(name: String, color: ColorKind): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + name + ) + + def pill(name: String, color: Signal[ColorKind]): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + cls <-- color.map(_.apply(800).text.toCSS), + cls <-- color.map(_.apply(100).bg.toCSS), + name + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala new file mode 100644 index 0000000..03a2c9c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait BreadcrumbsModule: + self: IconsModule => + + object breadcrumbs: + def container(mods: HtmlMod*): HtmlElement = + navTag(cls("flex"), aria.label("Breadcrumb"), mods) + + def list(mods: HtmlMod*): HtmlElement = + ol(cls("flex items-center space-x-4"), role("list"), mods) + + def homeItem(mods: HtmlMod*): HtmlElement = + li( + div( + a( + href("#"), + cls("text-gray-400 hover:text-gray-500"), + icons.home(svg.cls("h-5 w-5 flex-shrink-0")), + span(cls("sr-only"), "Home"), + mods + ) + ) + ) + + def item(mods: HtmlMod*): HtmlElement = + li( + div( + cls("flex items-center"), + icons.`chevron-right-solid`( + svg.cls("h-5 w-5 flex-shrink-0 text-gray-400") + ), + a( + href("#"), + cls("ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala new file mode 100644 index 0000000..d2bd069 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala @@ -0,0 +1,160 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind +import works.iterative.ui.components.laminar.tailwind.color.ColorWeight + +trait AlertComponentsModule: + self: IconsModule => + object alerts: + // TODO: use context functions and builder pattern to build the alerts + def alert( + message: HtmlMod, + icon: SvgElement, + color: ColorKind, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + div( + cls := "rounded-md p-4", + color(50).bg, + div( + cls := "flex", + icon, + div( + cls := "ml-3", + title.map(t => + h3( + cls := "text-sm font-medium", + color(800).text, + t + ) + ), + div( + cls := "text-sm", + title.map(_ => cls("mt-2")), + color(700).text, + message + ), + actions.map { act => + div( + cls := "mt-4", + act(color) + ) + } + ), + mods(color) + ) + ) + + def warning( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-warning`(), + ColorKind.yellow, + title = title, + actions = actions, + mods = mods + ) + + def error( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-error`(), + ColorKind.red, + title = title, + actions = actions, + mods = mods + ) + + def success( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-success`(), + ColorKind.green, + title = title, + actions = actions, + mods = mods + ) + + def info( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-info`(), + ColorKind.blue, + title = title, + actions = actions, + mods = mods + ) + + def buttons(buttons: (ColorKind => HtmlMod)*): ColorKind => HtmlMod = + color => + div( + cls("-mx-2 -my-1.5 flex"), + buttons.map(_(color)) + ) + + def button(title: String)(mods: HtmlMod*): ColorKind => HtmlMod = color => + L.button( + tpe("button"), + cls( + "first:ml-0 ml-3 rounded-md px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(800).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + title, + mods + ) + + def closeMod(mods: HtmlMod): ColorKind => HtmlMod = color => + val closeIcon: SvgElement = + import svg.* + svg( + cls := "h-5 w-5", + viewBox := "0 0 20 20", + fill := "currentColor", + path( + d := "M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" + ) + ) + div( + cls("ml-auto pl-3"), + L.button( + cls( + "-mx-1.5 -my-1.5 inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(500).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + mods, + aria.hidden := true, + closeIcon + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala new file mode 100644 index 0000000..fa374fe --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala @@ -0,0 +1,52 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait AppShellComponentsModule: + object shell: + object stackedLight extends StackedAppShell: + override def navCls = cls("border-b border-gray-200 bg-white") + override def headerCls = emptyMod + override def pageWrapper(content: HtmlMod*): HtmlMod = + div(cls("py-10"), content) + + object stackedBranded extends StackedAppShell: + override def navCls = cls("bg-indigo-600") + override def headerCls = cls("bg-white shadown-sm") + override def pageWrapper(content: HtmlMod*): HtmlMod = content + +trait StackedAppShell: + protected def navCls: HtmlMod + protected def headerCls: HtmlMod + protected def pageWrapper(content: HtmlMod*): HtmlMod + + def apply(pageTitle: HtmlMod)(navbarItems: HtmlMod*)( + content: HtmlMod* + ): HtmlElement = + div( + cls("min-h-full"), + navTag( + navCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + div(cls("flex h-16 items-center justify-between"), navbarItems) + ) + ), + pageWrapper( + headerTag( + headerCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + h1( + cls( + "text-3xl font-bold leading-tight tracking-tight text-gray-900" + ), + pageTitle + ) + ) + ), + mainTag( + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala new file mode 100644 index 0000000..f6d7d7f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait BadgeComponentsModule: + + object badges: + def pill(name: String, color: ColorKind): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + name + ) + + def pill(name: String, color: Signal[ColorKind]): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + cls <-- color.map(_.apply(800).text.toCSS), + cls <-- color.map(_.apply(100).bg.toCSS), + name + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala new file mode 100644 index 0000000..03a2c9c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait BreadcrumbsModule: + self: IconsModule => + + object breadcrumbs: + def container(mods: HtmlMod*): HtmlElement = + navTag(cls("flex"), aria.label("Breadcrumb"), mods) + + def list(mods: HtmlMod*): HtmlElement = + ol(cls("flex items-center space-x-4"), role("list"), mods) + + def homeItem(mods: HtmlMod*): HtmlElement = + li( + div( + a( + href("#"), + cls("text-gray-400 hover:text-gray-500"), + icons.home(svg.cls("h-5 w-5 flex-shrink-0")), + span(cls("sr-only"), "Home"), + mods + ) + ) + ) + + def item(mods: HtmlMod*): HtmlElement = + li( + div( + cls("flex items-center"), + icons.`chevron-right-solid`( + svg.cls("h-5 w-5 flex-shrink-0 text-gray-400") + ), + a( + href("#"), + cls("ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala new file mode 100644 index 0000000..27f3cf2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala @@ -0,0 +1,102 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +trait ButtonComponentsModule: + + object buttons: + + private inline def srHelp(text: String): Modifier[HtmlElement] = + span(cls := "sr-only", text) + + val sharedButtonClasses = + "inline-flex justify-center py-2 px-4 border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + + val sharedButtonMod: HtmlMod = cls(sharedButtonClasses) + + val primaryButtonClasses = + "border-transparent text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300" + val primaryButtonMod: HtmlMod = cls(primaryButtonClasses) + + val secondaryButtonClasses = + "border-gray-300 text-gray-700 hover:bg-gray-50" + val secondaryButtonMod: HtmlMod = cls(secondaryButtonClasses) + + val neutralButtonClasses = + "border-gray-300 text-gray-700 bg-white hover:bg-gray-50" + val neutralButtonMod: HtmlMod = cls(neutralButtonClasses) + + val positiveButtonClasses = + "border-transparent text-white bg-green-600 hover:bg-green-700" + val positiveButtonMod: HtmlMod = cls(positiveButtonClasses) + + val negativeButtonClasses = + "border-transparent text-white bg-red-600 hover:bg-red-700" + val negativeButtonMod: HtmlMod = cls(negativeButtonClasses) + + def button( + text: Modifier[HtmlElement], + id: Option[String], + icon: Option[SvgElement] = None, + buttonType: String = "submit", + primary: Boolean = false + )(mods: HtmlMod*): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe(buttonType), + sharedButtonMod, + if primary then primaryButtonMod else secondaryButtonMod, + icon, + text, + mods + ) + + def primaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "submit" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, true)(mods*) + + def secondaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "button" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, false)(mods*) + + def inlineButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srText.map(srHelp(_)), + icon + ) + + def iconButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srText.map(srHelp(_)), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala new file mode 100644 index 0000000..d2bd069 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala @@ -0,0 +1,160 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind +import works.iterative.ui.components.laminar.tailwind.color.ColorWeight + +trait AlertComponentsModule: + self: IconsModule => + object alerts: + // TODO: use context functions and builder pattern to build the alerts + def alert( + message: HtmlMod, + icon: SvgElement, + color: ColorKind, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + div( + cls := "rounded-md p-4", + color(50).bg, + div( + cls := "flex", + icon, + div( + cls := "ml-3", + title.map(t => + h3( + cls := "text-sm font-medium", + color(800).text, + t + ) + ), + div( + cls := "text-sm", + title.map(_ => cls("mt-2")), + color(700).text, + message + ), + actions.map { act => + div( + cls := "mt-4", + act(color) + ) + } + ), + mods(color) + ) + ) + + def warning( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-warning`(), + ColorKind.yellow, + title = title, + actions = actions, + mods = mods + ) + + def error( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-error`(), + ColorKind.red, + title = title, + actions = actions, + mods = mods + ) + + def success( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-success`(), + ColorKind.green, + title = title, + actions = actions, + mods = mods + ) + + def info( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-info`(), + ColorKind.blue, + title = title, + actions = actions, + mods = mods + ) + + def buttons(buttons: (ColorKind => HtmlMod)*): ColorKind => HtmlMod = + color => + div( + cls("-mx-2 -my-1.5 flex"), + buttons.map(_(color)) + ) + + def button(title: String)(mods: HtmlMod*): ColorKind => HtmlMod = color => + L.button( + tpe("button"), + cls( + "first:ml-0 ml-3 rounded-md px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(800).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + title, + mods + ) + + def closeMod(mods: HtmlMod): ColorKind => HtmlMod = color => + val closeIcon: SvgElement = + import svg.* + svg( + cls := "h-5 w-5", + viewBox := "0 0 20 20", + fill := "currentColor", + path( + d := "M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" + ) + ) + div( + cls("ml-auto pl-3"), + L.button( + cls( + "-mx-1.5 -my-1.5 inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(500).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + mods, + aria.hidden := true, + closeIcon + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala new file mode 100644 index 0000000..fa374fe --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala @@ -0,0 +1,52 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait AppShellComponentsModule: + object shell: + object stackedLight extends StackedAppShell: + override def navCls = cls("border-b border-gray-200 bg-white") + override def headerCls = emptyMod + override def pageWrapper(content: HtmlMod*): HtmlMod = + div(cls("py-10"), content) + + object stackedBranded extends StackedAppShell: + override def navCls = cls("bg-indigo-600") + override def headerCls = cls("bg-white shadown-sm") + override def pageWrapper(content: HtmlMod*): HtmlMod = content + +trait StackedAppShell: + protected def navCls: HtmlMod + protected def headerCls: HtmlMod + protected def pageWrapper(content: HtmlMod*): HtmlMod + + def apply(pageTitle: HtmlMod)(navbarItems: HtmlMod*)( + content: HtmlMod* + ): HtmlElement = + div( + cls("min-h-full"), + navTag( + navCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + div(cls("flex h-16 items-center justify-between"), navbarItems) + ) + ), + pageWrapper( + headerTag( + headerCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + h1( + cls( + "text-3xl font-bold leading-tight tracking-tight text-gray-900" + ), + pageTitle + ) + ) + ), + mainTag( + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala new file mode 100644 index 0000000..f6d7d7f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait BadgeComponentsModule: + + object badges: + def pill(name: String, color: ColorKind): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + name + ) + + def pill(name: String, color: Signal[ColorKind]): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + cls <-- color.map(_.apply(800).text.toCSS), + cls <-- color.map(_.apply(100).bg.toCSS), + name + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala new file mode 100644 index 0000000..03a2c9c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait BreadcrumbsModule: + self: IconsModule => + + object breadcrumbs: + def container(mods: HtmlMod*): HtmlElement = + navTag(cls("flex"), aria.label("Breadcrumb"), mods) + + def list(mods: HtmlMod*): HtmlElement = + ol(cls("flex items-center space-x-4"), role("list"), mods) + + def homeItem(mods: HtmlMod*): HtmlElement = + li( + div( + a( + href("#"), + cls("text-gray-400 hover:text-gray-500"), + icons.home(svg.cls("h-5 w-5 flex-shrink-0")), + span(cls("sr-only"), "Home"), + mods + ) + ) + ) + + def item(mods: HtmlMod*): HtmlElement = + li( + div( + cls("flex items-center"), + icons.`chevron-right-solid`( + svg.cls("h-5 w-5 flex-shrink-0 text-gray-400") + ), + a( + href("#"), + cls("ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala new file mode 100644 index 0000000..27f3cf2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala @@ -0,0 +1,102 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +trait ButtonComponentsModule: + + object buttons: + + private inline def srHelp(text: String): Modifier[HtmlElement] = + span(cls := "sr-only", text) + + val sharedButtonClasses = + "inline-flex justify-center py-2 px-4 border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + + val sharedButtonMod: HtmlMod = cls(sharedButtonClasses) + + val primaryButtonClasses = + "border-transparent text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300" + val primaryButtonMod: HtmlMod = cls(primaryButtonClasses) + + val secondaryButtonClasses = + "border-gray-300 text-gray-700 hover:bg-gray-50" + val secondaryButtonMod: HtmlMod = cls(secondaryButtonClasses) + + val neutralButtonClasses = + "border-gray-300 text-gray-700 bg-white hover:bg-gray-50" + val neutralButtonMod: HtmlMod = cls(neutralButtonClasses) + + val positiveButtonClasses = + "border-transparent text-white bg-green-600 hover:bg-green-700" + val positiveButtonMod: HtmlMod = cls(positiveButtonClasses) + + val negativeButtonClasses = + "border-transparent text-white bg-red-600 hover:bg-red-700" + val negativeButtonMod: HtmlMod = cls(negativeButtonClasses) + + def button( + text: Modifier[HtmlElement], + id: Option[String], + icon: Option[SvgElement] = None, + buttonType: String = "submit", + primary: Boolean = false + )(mods: HtmlMod*): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe(buttonType), + sharedButtonMod, + if primary then primaryButtonMod else secondaryButtonMod, + icon, + text, + mods + ) + + def primaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "submit" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, true)(mods*) + + def secondaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "button" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, false)(mods*) + + def inlineButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srText.map(srHelp(_)), + icon + ) + + def iconButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srText.map(srHelp(_)), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala new file mode 100644 index 0000000..ae4ab86 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala @@ -0,0 +1,56 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +import io.laminext.syntax.core.* + +trait ComboboxModule: + self: IconsModule => + + object combobox: + object simple: + def container(mods: HtmlMod*): Div = div(cls("relative mt-2"), mods) + + def button(mods: HtmlMod*): Button = + L.button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + mods + ) + + def options(mods: HtmlMod*) = + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + role := "listbox", + mods + ) + + def option(isActive: Signal[Boolean], isSelected: Signal[Boolean])( + mods: HtmlMod* + ) = + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- isActive, + cls.toggle("text-gray-900") <-- isActive.not, + cls.toggle("font-semibold") <-- isSelected, + mods + ) + + def optionValue(l: String): HtmlElement = + span(cls("block truncate"), l) + + def checkmark(isActive: Signal[Boolean]): HtmlElement = + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- isActive.not, + cls.toggle("text-white") <-- isActive, + icons.check(svg.cls("h-5 w-5")) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala new file mode 100644 index 0000000..d2bd069 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala @@ -0,0 +1,160 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind +import works.iterative.ui.components.laminar.tailwind.color.ColorWeight + +trait AlertComponentsModule: + self: IconsModule => + object alerts: + // TODO: use context functions and builder pattern to build the alerts + def alert( + message: HtmlMod, + icon: SvgElement, + color: ColorKind, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + div( + cls := "rounded-md p-4", + color(50).bg, + div( + cls := "flex", + icon, + div( + cls := "ml-3", + title.map(t => + h3( + cls := "text-sm font-medium", + color(800).text, + t + ) + ), + div( + cls := "text-sm", + title.map(_ => cls("mt-2")), + color(700).text, + message + ), + actions.map { act => + div( + cls := "mt-4", + act(color) + ) + } + ), + mods(color) + ) + ) + + def warning( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-warning`(), + ColorKind.yellow, + title = title, + actions = actions, + mods = mods + ) + + def error( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-error`(), + ColorKind.red, + title = title, + actions = actions, + mods = mods + ) + + def success( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-success`(), + ColorKind.green, + title = title, + actions = actions, + mods = mods + ) + + def info( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-info`(), + ColorKind.blue, + title = title, + actions = actions, + mods = mods + ) + + def buttons(buttons: (ColorKind => HtmlMod)*): ColorKind => HtmlMod = + color => + div( + cls("-mx-2 -my-1.5 flex"), + buttons.map(_(color)) + ) + + def button(title: String)(mods: HtmlMod*): ColorKind => HtmlMod = color => + L.button( + tpe("button"), + cls( + "first:ml-0 ml-3 rounded-md px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(800).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + title, + mods + ) + + def closeMod(mods: HtmlMod): ColorKind => HtmlMod = color => + val closeIcon: SvgElement = + import svg.* + svg( + cls := "h-5 w-5", + viewBox := "0 0 20 20", + fill := "currentColor", + path( + d := "M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" + ) + ) + div( + cls("ml-auto pl-3"), + L.button( + cls( + "-mx-1.5 -my-1.5 inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(500).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + mods, + aria.hidden := true, + closeIcon + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala new file mode 100644 index 0000000..fa374fe --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala @@ -0,0 +1,52 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait AppShellComponentsModule: + object shell: + object stackedLight extends StackedAppShell: + override def navCls = cls("border-b border-gray-200 bg-white") + override def headerCls = emptyMod + override def pageWrapper(content: HtmlMod*): HtmlMod = + div(cls("py-10"), content) + + object stackedBranded extends StackedAppShell: + override def navCls = cls("bg-indigo-600") + override def headerCls = cls("bg-white shadown-sm") + override def pageWrapper(content: HtmlMod*): HtmlMod = content + +trait StackedAppShell: + protected def navCls: HtmlMod + protected def headerCls: HtmlMod + protected def pageWrapper(content: HtmlMod*): HtmlMod + + def apply(pageTitle: HtmlMod)(navbarItems: HtmlMod*)( + content: HtmlMod* + ): HtmlElement = + div( + cls("min-h-full"), + navTag( + navCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + div(cls("flex h-16 items-center justify-between"), navbarItems) + ) + ), + pageWrapper( + headerTag( + headerCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + h1( + cls( + "text-3xl font-bold leading-tight tracking-tight text-gray-900" + ), + pageTitle + ) + ) + ), + mainTag( + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala new file mode 100644 index 0000000..f6d7d7f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait BadgeComponentsModule: + + object badges: + def pill(name: String, color: ColorKind): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + name + ) + + def pill(name: String, color: Signal[ColorKind]): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + cls <-- color.map(_.apply(800).text.toCSS), + cls <-- color.map(_.apply(100).bg.toCSS), + name + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala new file mode 100644 index 0000000..03a2c9c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait BreadcrumbsModule: + self: IconsModule => + + object breadcrumbs: + def container(mods: HtmlMod*): HtmlElement = + navTag(cls("flex"), aria.label("Breadcrumb"), mods) + + def list(mods: HtmlMod*): HtmlElement = + ol(cls("flex items-center space-x-4"), role("list"), mods) + + def homeItem(mods: HtmlMod*): HtmlElement = + li( + div( + a( + href("#"), + cls("text-gray-400 hover:text-gray-500"), + icons.home(svg.cls("h-5 w-5 flex-shrink-0")), + span(cls("sr-only"), "Home"), + mods + ) + ) + ) + + def item(mods: HtmlMod*): HtmlElement = + li( + div( + cls("flex items-center"), + icons.`chevron-right-solid`( + svg.cls("h-5 w-5 flex-shrink-0 text-gray-400") + ), + a( + href("#"), + cls("ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala new file mode 100644 index 0000000..27f3cf2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala @@ -0,0 +1,102 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +trait ButtonComponentsModule: + + object buttons: + + private inline def srHelp(text: String): Modifier[HtmlElement] = + span(cls := "sr-only", text) + + val sharedButtonClasses = + "inline-flex justify-center py-2 px-4 border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + + val sharedButtonMod: HtmlMod = cls(sharedButtonClasses) + + val primaryButtonClasses = + "border-transparent text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300" + val primaryButtonMod: HtmlMod = cls(primaryButtonClasses) + + val secondaryButtonClasses = + "border-gray-300 text-gray-700 hover:bg-gray-50" + val secondaryButtonMod: HtmlMod = cls(secondaryButtonClasses) + + val neutralButtonClasses = + "border-gray-300 text-gray-700 bg-white hover:bg-gray-50" + val neutralButtonMod: HtmlMod = cls(neutralButtonClasses) + + val positiveButtonClasses = + "border-transparent text-white bg-green-600 hover:bg-green-700" + val positiveButtonMod: HtmlMod = cls(positiveButtonClasses) + + val negativeButtonClasses = + "border-transparent text-white bg-red-600 hover:bg-red-700" + val negativeButtonMod: HtmlMod = cls(negativeButtonClasses) + + def button( + text: Modifier[HtmlElement], + id: Option[String], + icon: Option[SvgElement] = None, + buttonType: String = "submit", + primary: Boolean = false + )(mods: HtmlMod*): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe(buttonType), + sharedButtonMod, + if primary then primaryButtonMod else secondaryButtonMod, + icon, + text, + mods + ) + + def primaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "submit" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, true)(mods*) + + def secondaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "button" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, false)(mods*) + + def inlineButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srText.map(srHelp(_)), + icon + ) + + def iconButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srText.map(srHelp(_)), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala new file mode 100644 index 0000000..ae4ab86 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala @@ -0,0 +1,56 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +import io.laminext.syntax.core.* + +trait ComboboxModule: + self: IconsModule => + + object combobox: + object simple: + def container(mods: HtmlMod*): Div = div(cls("relative mt-2"), mods) + + def button(mods: HtmlMod*): Button = + L.button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + mods + ) + + def options(mods: HtmlMod*) = + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + role := "listbox", + mods + ) + + def option(isActive: Signal[Boolean], isSelected: Signal[Boolean])( + mods: HtmlMod* + ) = + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- isActive, + cls.toggle("text-gray-900") <-- isActive.not, + cls.toggle("font-semibold") <-- isSelected, + mods + ) + + def optionValue(l: String): HtmlElement = + span(cls("block truncate"), l) + + def checkmark(isActive: Signal[Boolean]): HtmlElement = + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- isActive.not, + cls.toggle("text-white") <-- isActive, + icons.check(svg.cls("h-5 w-5")) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala new file mode 100644 index 0000000..f5a8873 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ContainerComponentsModule: + object container: + /** Full-width on mobile, constrained with padded content above */ + def default(content: HtmlMod*): HtmlElement = + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + + def padded(content: HtmlMod*): HtmlElement = + default(cls("px-4"), content) + + def narrow(content: HtmlMod*): HtmlElement = + padded( + div(cls("mx-auto max-w-3xl"), content) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala new file mode 100644 index 0000000..d2bd069 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala @@ -0,0 +1,160 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind +import works.iterative.ui.components.laminar.tailwind.color.ColorWeight + +trait AlertComponentsModule: + self: IconsModule => + object alerts: + // TODO: use context functions and builder pattern to build the alerts + def alert( + message: HtmlMod, + icon: SvgElement, + color: ColorKind, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + div( + cls := "rounded-md p-4", + color(50).bg, + div( + cls := "flex", + icon, + div( + cls := "ml-3", + title.map(t => + h3( + cls := "text-sm font-medium", + color(800).text, + t + ) + ), + div( + cls := "text-sm", + title.map(_ => cls("mt-2")), + color(700).text, + message + ), + actions.map { act => + div( + cls := "mt-4", + act(color) + ) + } + ), + mods(color) + ) + ) + + def warning( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-warning`(), + ColorKind.yellow, + title = title, + actions = actions, + mods = mods + ) + + def error( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-error`(), + ColorKind.red, + title = title, + actions = actions, + mods = mods + ) + + def success( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-success`(), + ColorKind.green, + title = title, + actions = actions, + mods = mods + ) + + def info( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-info`(), + ColorKind.blue, + title = title, + actions = actions, + mods = mods + ) + + def buttons(buttons: (ColorKind => HtmlMod)*): ColorKind => HtmlMod = + color => + div( + cls("-mx-2 -my-1.5 flex"), + buttons.map(_(color)) + ) + + def button(title: String)(mods: HtmlMod*): ColorKind => HtmlMod = color => + L.button( + tpe("button"), + cls( + "first:ml-0 ml-3 rounded-md px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(800).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + title, + mods + ) + + def closeMod(mods: HtmlMod): ColorKind => HtmlMod = color => + val closeIcon: SvgElement = + import svg.* + svg( + cls := "h-5 w-5", + viewBox := "0 0 20 20", + fill := "currentColor", + path( + d := "M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" + ) + ) + div( + cls("ml-auto pl-3"), + L.button( + cls( + "-mx-1.5 -my-1.5 inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(500).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + mods, + aria.hidden := true, + closeIcon + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala new file mode 100644 index 0000000..fa374fe --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala @@ -0,0 +1,52 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait AppShellComponentsModule: + object shell: + object stackedLight extends StackedAppShell: + override def navCls = cls("border-b border-gray-200 bg-white") + override def headerCls = emptyMod + override def pageWrapper(content: HtmlMod*): HtmlMod = + div(cls("py-10"), content) + + object stackedBranded extends StackedAppShell: + override def navCls = cls("bg-indigo-600") + override def headerCls = cls("bg-white shadown-sm") + override def pageWrapper(content: HtmlMod*): HtmlMod = content + +trait StackedAppShell: + protected def navCls: HtmlMod + protected def headerCls: HtmlMod + protected def pageWrapper(content: HtmlMod*): HtmlMod + + def apply(pageTitle: HtmlMod)(navbarItems: HtmlMod*)( + content: HtmlMod* + ): HtmlElement = + div( + cls("min-h-full"), + navTag( + navCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + div(cls("flex h-16 items-center justify-between"), navbarItems) + ) + ), + pageWrapper( + headerTag( + headerCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + h1( + cls( + "text-3xl font-bold leading-tight tracking-tight text-gray-900" + ), + pageTitle + ) + ) + ), + mainTag( + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala new file mode 100644 index 0000000..f6d7d7f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait BadgeComponentsModule: + + object badges: + def pill(name: String, color: ColorKind): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + name + ) + + def pill(name: String, color: Signal[ColorKind]): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + cls <-- color.map(_.apply(800).text.toCSS), + cls <-- color.map(_.apply(100).bg.toCSS), + name + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala new file mode 100644 index 0000000..03a2c9c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait BreadcrumbsModule: + self: IconsModule => + + object breadcrumbs: + def container(mods: HtmlMod*): HtmlElement = + navTag(cls("flex"), aria.label("Breadcrumb"), mods) + + def list(mods: HtmlMod*): HtmlElement = + ol(cls("flex items-center space-x-4"), role("list"), mods) + + def homeItem(mods: HtmlMod*): HtmlElement = + li( + div( + a( + href("#"), + cls("text-gray-400 hover:text-gray-500"), + icons.home(svg.cls("h-5 w-5 flex-shrink-0")), + span(cls("sr-only"), "Home"), + mods + ) + ) + ) + + def item(mods: HtmlMod*): HtmlElement = + li( + div( + cls("flex items-center"), + icons.`chevron-right-solid`( + svg.cls("h-5 w-5 flex-shrink-0 text-gray-400") + ), + a( + href("#"), + cls("ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala new file mode 100644 index 0000000..27f3cf2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala @@ -0,0 +1,102 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +trait ButtonComponentsModule: + + object buttons: + + private inline def srHelp(text: String): Modifier[HtmlElement] = + span(cls := "sr-only", text) + + val sharedButtonClasses = + "inline-flex justify-center py-2 px-4 border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + + val sharedButtonMod: HtmlMod = cls(sharedButtonClasses) + + val primaryButtonClasses = + "border-transparent text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300" + val primaryButtonMod: HtmlMod = cls(primaryButtonClasses) + + val secondaryButtonClasses = + "border-gray-300 text-gray-700 hover:bg-gray-50" + val secondaryButtonMod: HtmlMod = cls(secondaryButtonClasses) + + val neutralButtonClasses = + "border-gray-300 text-gray-700 bg-white hover:bg-gray-50" + val neutralButtonMod: HtmlMod = cls(neutralButtonClasses) + + val positiveButtonClasses = + "border-transparent text-white bg-green-600 hover:bg-green-700" + val positiveButtonMod: HtmlMod = cls(positiveButtonClasses) + + val negativeButtonClasses = + "border-transparent text-white bg-red-600 hover:bg-red-700" + val negativeButtonMod: HtmlMod = cls(negativeButtonClasses) + + def button( + text: Modifier[HtmlElement], + id: Option[String], + icon: Option[SvgElement] = None, + buttonType: String = "submit", + primary: Boolean = false + )(mods: HtmlMod*): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe(buttonType), + sharedButtonMod, + if primary then primaryButtonMod else secondaryButtonMod, + icon, + text, + mods + ) + + def primaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "submit" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, true)(mods*) + + def secondaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "button" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, false)(mods*) + + def inlineButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srText.map(srHelp(_)), + icon + ) + + def iconButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srText.map(srHelp(_)), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala new file mode 100644 index 0000000..ae4ab86 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala @@ -0,0 +1,56 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +import io.laminext.syntax.core.* + +trait ComboboxModule: + self: IconsModule => + + object combobox: + object simple: + def container(mods: HtmlMod*): Div = div(cls("relative mt-2"), mods) + + def button(mods: HtmlMod*): Button = + L.button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + mods + ) + + def options(mods: HtmlMod*) = + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + role := "listbox", + mods + ) + + def option(isActive: Signal[Boolean], isSelected: Signal[Boolean])( + mods: HtmlMod* + ) = + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- isActive, + cls.toggle("text-gray-900") <-- isActive.not, + cls.toggle("font-semibold") <-- isSelected, + mods + ) + + def optionValue(l: String): HtmlElement = + span(cls("block truncate"), l) + + def checkmark(isActive: Signal[Boolean]): HtmlElement = + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- isActive.not, + cls.toggle("text-white") <-- isActive, + icons.check(svg.cls("h-5 w-5")) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala new file mode 100644 index 0000000..f5a8873 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ContainerComponentsModule: + object container: + /** Full-width on mobile, constrained with padded content above */ + def default(content: HtmlMod*): HtmlElement = + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + + def padded(content: HtmlMod*): HtmlElement = + default(cls("px-4"), content) + + def narrow(content: HtmlMod*): HtmlElement = + padded( + div(cls("mx-auto max-w-3xl"), content) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala new file mode 100644 index 0000000..d601ab6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala @@ -0,0 +1,117 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.core.FileRef +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.laminar.* +import works.iterative.core.UserMessage + +trait DetailComponentsModule: + self: IconsModule => + object details: + def sectionHeader( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls( + "flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls := "px-4 sm:px-0", + h3( + cls := "text-base font-semibold leading-7 text-gray-900", + title + ), + subtitle.map(st => + p( + cls := "mt-1 max-w-2xl text-sm leading-6 text-gray-500", + st + ) + ) + ), + div(cls("flex-shrink-0"), actions) + ) + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + )(content: HtmlMod): HtmlElement = + div(sectionHeader(title, subtitle, actions), content) + + def fields(items: Node*): HtmlElement = + div( + dl( + cls := "divide-y divide-gray-100", + items + ) + ) + + def field( + title: Node, + content: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "px-2 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0", + dt( + cls := "text-sm font-medium leading-6 text-gray-900", + title + ), + dd( + cls := "mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0", + content + ) + ) + + def files( + fs: Seq[FileRef], + fileMods: Option[(FileRef, Int) => HtmlMod] = None + )( + mods: HtmlMod* + )(using ComponentContext[?]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-100 rounded-md border border-gray-200", + fs.zipWithIndex + .map((f, i) => + fileMods match + case Some(fm) => file(f)(fm(f, i)) + case _ => file(f)() + ), + mods + ) + + def file(f: FileRef)(mods: HtmlMod*)(using + ComponentContext[?] + ): HtmlElement = + li( + cls := "flex items-center justify-between py-4 pl-4 pr-5 text-sm leading-6", + div( + cls := "flex w-0 flex-1 items-center", + icons.`paper-clip-solid`(), + div( + cls := "ml-4 flex min-w-0 flex-1 gap-2", + span( + cls := "truncate font-medium", + f.name + ), + f.sizeString.map(size => + span(cls := "flex-shrink-0 text-gray-400", size) + ) + ) + ), + div( + cls := "ml-4 flex-shrink-0", + a( + href := f.url, + target := "_blank", + cls := "font-medium text-indigo-600 hover:text-indigo-500", + UserMessage("file.download").asString + ) + ), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala new file mode 100644 index 0000000..d2bd069 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala @@ -0,0 +1,160 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind +import works.iterative.ui.components.laminar.tailwind.color.ColorWeight + +trait AlertComponentsModule: + self: IconsModule => + object alerts: + // TODO: use context functions and builder pattern to build the alerts + def alert( + message: HtmlMod, + icon: SvgElement, + color: ColorKind, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + div( + cls := "rounded-md p-4", + color(50).bg, + div( + cls := "flex", + icon, + div( + cls := "ml-3", + title.map(t => + h3( + cls := "text-sm font-medium", + color(800).text, + t + ) + ), + div( + cls := "text-sm", + title.map(_ => cls("mt-2")), + color(700).text, + message + ), + actions.map { act => + div( + cls := "mt-4", + act(color) + ) + } + ), + mods(color) + ) + ) + + def warning( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-warning`(), + ColorKind.yellow, + title = title, + actions = actions, + mods = mods + ) + + def error( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-error`(), + ColorKind.red, + title = title, + actions = actions, + mods = mods + ) + + def success( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-success`(), + ColorKind.green, + title = title, + actions = actions, + mods = mods + ) + + def info( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-info`(), + ColorKind.blue, + title = title, + actions = actions, + mods = mods + ) + + def buttons(buttons: (ColorKind => HtmlMod)*): ColorKind => HtmlMod = + color => + div( + cls("-mx-2 -my-1.5 flex"), + buttons.map(_(color)) + ) + + def button(title: String)(mods: HtmlMod*): ColorKind => HtmlMod = color => + L.button( + tpe("button"), + cls( + "first:ml-0 ml-3 rounded-md px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(800).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + title, + mods + ) + + def closeMod(mods: HtmlMod): ColorKind => HtmlMod = color => + val closeIcon: SvgElement = + import svg.* + svg( + cls := "h-5 w-5", + viewBox := "0 0 20 20", + fill := "currentColor", + path( + d := "M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" + ) + ) + div( + cls("ml-auto pl-3"), + L.button( + cls( + "-mx-1.5 -my-1.5 inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(500).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + mods, + aria.hidden := true, + closeIcon + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala new file mode 100644 index 0000000..fa374fe --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala @@ -0,0 +1,52 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait AppShellComponentsModule: + object shell: + object stackedLight extends StackedAppShell: + override def navCls = cls("border-b border-gray-200 bg-white") + override def headerCls = emptyMod + override def pageWrapper(content: HtmlMod*): HtmlMod = + div(cls("py-10"), content) + + object stackedBranded extends StackedAppShell: + override def navCls = cls("bg-indigo-600") + override def headerCls = cls("bg-white shadown-sm") + override def pageWrapper(content: HtmlMod*): HtmlMod = content + +trait StackedAppShell: + protected def navCls: HtmlMod + protected def headerCls: HtmlMod + protected def pageWrapper(content: HtmlMod*): HtmlMod + + def apply(pageTitle: HtmlMod)(navbarItems: HtmlMod*)( + content: HtmlMod* + ): HtmlElement = + div( + cls("min-h-full"), + navTag( + navCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + div(cls("flex h-16 items-center justify-between"), navbarItems) + ) + ), + pageWrapper( + headerTag( + headerCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + h1( + cls( + "text-3xl font-bold leading-tight tracking-tight text-gray-900" + ), + pageTitle + ) + ) + ), + mainTag( + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala new file mode 100644 index 0000000..f6d7d7f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait BadgeComponentsModule: + + object badges: + def pill(name: String, color: ColorKind): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + name + ) + + def pill(name: String, color: Signal[ColorKind]): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + cls <-- color.map(_.apply(800).text.toCSS), + cls <-- color.map(_.apply(100).bg.toCSS), + name + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala new file mode 100644 index 0000000..03a2c9c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait BreadcrumbsModule: + self: IconsModule => + + object breadcrumbs: + def container(mods: HtmlMod*): HtmlElement = + navTag(cls("flex"), aria.label("Breadcrumb"), mods) + + def list(mods: HtmlMod*): HtmlElement = + ol(cls("flex items-center space-x-4"), role("list"), mods) + + def homeItem(mods: HtmlMod*): HtmlElement = + li( + div( + a( + href("#"), + cls("text-gray-400 hover:text-gray-500"), + icons.home(svg.cls("h-5 w-5 flex-shrink-0")), + span(cls("sr-only"), "Home"), + mods + ) + ) + ) + + def item(mods: HtmlMod*): HtmlElement = + li( + div( + cls("flex items-center"), + icons.`chevron-right-solid`( + svg.cls("h-5 w-5 flex-shrink-0 text-gray-400") + ), + a( + href("#"), + cls("ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala new file mode 100644 index 0000000..27f3cf2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala @@ -0,0 +1,102 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +trait ButtonComponentsModule: + + object buttons: + + private inline def srHelp(text: String): Modifier[HtmlElement] = + span(cls := "sr-only", text) + + val sharedButtonClasses = + "inline-flex justify-center py-2 px-4 border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + + val sharedButtonMod: HtmlMod = cls(sharedButtonClasses) + + val primaryButtonClasses = + "border-transparent text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300" + val primaryButtonMod: HtmlMod = cls(primaryButtonClasses) + + val secondaryButtonClasses = + "border-gray-300 text-gray-700 hover:bg-gray-50" + val secondaryButtonMod: HtmlMod = cls(secondaryButtonClasses) + + val neutralButtonClasses = + "border-gray-300 text-gray-700 bg-white hover:bg-gray-50" + val neutralButtonMod: HtmlMod = cls(neutralButtonClasses) + + val positiveButtonClasses = + "border-transparent text-white bg-green-600 hover:bg-green-700" + val positiveButtonMod: HtmlMod = cls(positiveButtonClasses) + + val negativeButtonClasses = + "border-transparent text-white bg-red-600 hover:bg-red-700" + val negativeButtonMod: HtmlMod = cls(negativeButtonClasses) + + def button( + text: Modifier[HtmlElement], + id: Option[String], + icon: Option[SvgElement] = None, + buttonType: String = "submit", + primary: Boolean = false + )(mods: HtmlMod*): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe(buttonType), + sharedButtonMod, + if primary then primaryButtonMod else secondaryButtonMod, + icon, + text, + mods + ) + + def primaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "submit" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, true)(mods*) + + def secondaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "button" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, false)(mods*) + + def inlineButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srText.map(srHelp(_)), + icon + ) + + def iconButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srText.map(srHelp(_)), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala new file mode 100644 index 0000000..ae4ab86 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala @@ -0,0 +1,56 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +import io.laminext.syntax.core.* + +trait ComboboxModule: + self: IconsModule => + + object combobox: + object simple: + def container(mods: HtmlMod*): Div = div(cls("relative mt-2"), mods) + + def button(mods: HtmlMod*): Button = + L.button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + mods + ) + + def options(mods: HtmlMod*) = + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + role := "listbox", + mods + ) + + def option(isActive: Signal[Boolean], isSelected: Signal[Boolean])( + mods: HtmlMod* + ) = + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- isActive, + cls.toggle("text-gray-900") <-- isActive.not, + cls.toggle("font-semibold") <-- isSelected, + mods + ) + + def optionValue(l: String): HtmlElement = + span(cls("block truncate"), l) + + def checkmark(isActive: Signal[Boolean]): HtmlElement = + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- isActive.not, + cls.toggle("text-white") <-- isActive, + icons.check(svg.cls("h-5 w-5")) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala new file mode 100644 index 0000000..f5a8873 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ContainerComponentsModule: + object container: + /** Full-width on mobile, constrained with padded content above */ + def default(content: HtmlMod*): HtmlElement = + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + + def padded(content: HtmlMod*): HtmlElement = + default(cls("px-4"), content) + + def narrow(content: HtmlMod*): HtmlElement = + padded( + div(cls("mx-auto max-w-3xl"), content) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala new file mode 100644 index 0000000..d601ab6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala @@ -0,0 +1,117 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.core.FileRef +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.laminar.* +import works.iterative.core.UserMessage + +trait DetailComponentsModule: + self: IconsModule => + object details: + def sectionHeader( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls( + "flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls := "px-4 sm:px-0", + h3( + cls := "text-base font-semibold leading-7 text-gray-900", + title + ), + subtitle.map(st => + p( + cls := "mt-1 max-w-2xl text-sm leading-6 text-gray-500", + st + ) + ) + ), + div(cls("flex-shrink-0"), actions) + ) + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + )(content: HtmlMod): HtmlElement = + div(sectionHeader(title, subtitle, actions), content) + + def fields(items: Node*): HtmlElement = + div( + dl( + cls := "divide-y divide-gray-100", + items + ) + ) + + def field( + title: Node, + content: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "px-2 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0", + dt( + cls := "text-sm font-medium leading-6 text-gray-900", + title + ), + dd( + cls := "mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0", + content + ) + ) + + def files( + fs: Seq[FileRef], + fileMods: Option[(FileRef, Int) => HtmlMod] = None + )( + mods: HtmlMod* + )(using ComponentContext[?]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-100 rounded-md border border-gray-200", + fs.zipWithIndex + .map((f, i) => + fileMods match + case Some(fm) => file(f)(fm(f, i)) + case _ => file(f)() + ), + mods + ) + + def file(f: FileRef)(mods: HtmlMod*)(using + ComponentContext[?] + ): HtmlElement = + li( + cls := "flex items-center justify-between py-4 pl-4 pr-5 text-sm leading-6", + div( + cls := "flex w-0 flex-1 items-center", + icons.`paper-clip-solid`(), + div( + cls := "ml-4 flex min-w-0 flex-1 gap-2", + span( + cls := "truncate font-medium", + f.name + ), + f.sizeString.map(size => + span(cls := "flex-shrink-0 text-gray-400", size) + ) + ) + ), + div( + cls := "ml-4 flex-shrink-0", + a( + href := f.url, + target := "_blank", + cls := "font-medium text-indigo-600 hover:text-indigo-500", + UserMessage("file.download").asString + ) + ), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala new file mode 100644 index 0000000..e9d7f1b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala @@ -0,0 +1,202 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html +import io.laminext.syntax.core.* + +trait FormComponentsModule: + self: IconsModule => + + object forms: + + val inputClasses = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls("space-y-6 sm:space-y-5"), + div( + h3(cls("text-lg leading-6 font-medium text-gray-900"), title), + subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) + ), + div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) + ) + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: Modifier[HtmlElement]* + ): ReactiveHtmlElement[html.Label] = + L.label( + cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), + forId.map(id => L.forId(id)), + labelText, + if required then sup(cls("text-gray-400"), "* povinné pole") + else emptyMod, + mods + ) + + def field( + label: Modifier[HtmlElement] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls( + "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5" + ), + label, + div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) + ) + + def field( + id: String, + labelText: String, + input: HtmlElement, + help: Option[String] + ): HtmlElement = + field( + label(labelText, Some(id))() + )( + input.amend(idAttr(id)), + help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) + ) + + def form(mods: Modifier[HtmlElement]*)( + sections: Modifier[HtmlElement]* + )(actions: Modifier[HtmlElement]*): ReactiveHtmlElement[html.Form] = + L.form( + cls("space-y-8 divide-y divide-gray-200"), + mods, + sections, + div( + cls("pt-5"), + div(cls("flex justify-end"), actions) + ) + ) + + def inlineForm( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + def errorTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-red-600") + + def validationError(text: Modifier[HtmlElement]): HtmlElement = + p(errorTextMods, text) + + def helpTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-gray-500") + + def fieldHelp(text: Modifier[HtmlElement]): HtmlElement = + p(helpTextMods, text) + + def inputField( + id: String, + labelText: String, + placeholderText: Option[String] = None, + inputType: String = "text", + helpText: Option[String] = None + ): HtmlElement = + field( + id, + labelText, + input(id, inputType, placeholderText)(), + helpText + ) + + def input( + id: String, + inputType: String = "text", + placeholderText: Option[String] = None + )(mods: HtmlMod*): HtmlElement = + L.input( + cls(inputClasses), + idAttr(id), + nameAttr(id), + placeholderText.map(placeholder(_)), + tpe(inputType), + mods + ) + + def comboBoxSimple( + options: List[(String, String)], + selectedInitially: Option[String] = None, + id: Option[String] = None, + name: Option[String] = None + ): HtmlElement = + val expanded = Var(false) + val selected = Var(selectedInitially) + div( + cls("relative mt-2"), + L.input( + cls( + "w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + id.map(idAttr(_)), + name.map(nameAttr(_)), + tpe("text"), + role("combobox"), + aria.controls("options"), + aria.expanded <-- expanded + ), + button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + onClick.preventDefault --> (_ => expanded.toggle()) + ), + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + cls.toggle("hidden") <-- expanded.signal.not, + id.map(i => idAttr(s"${i}-options")), + role := "listbox", + // Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation. + // Active: "text-white bg-indigo-600", Not Active: "text-gray-900" + for (((v, l), i) <- options.zipWithIndex) + yield + val active = Var(false) + val isSelected = selected.signal.map(_.contains(v)) + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- active.signal, + cls.toggle("text-gray-900") <-- active.signal.not, + cls.toggle("font-semibold") <-- isSelected, + id.map(cid => idAttr(s"${cid}-option-${i}")), + role := "option", + tabIndex := -1, + // Selected: "font-semibold" + span(cls("block truncate"), l), + // Checkmark, only display for selected option. + // Active: "text-white", Not Active: "text-indigo-600" + isSelected.childWhenTrue( + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- active.signal.not, + cls.toggle("text-white") <-- active.signal, + icons.check(svg.cls("h-5 w-5")) + ) + ), + onClick.preventDefault.mapTo( + v + ) --> selected.writer.contramapSome, + onMouseEnter --> (_ => active.set(true)), + onMouseLeave --> (_ => active.set(false)) + ) + // More items... + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala new file mode 100644 index 0000000..d2bd069 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala @@ -0,0 +1,160 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind +import works.iterative.ui.components.laminar.tailwind.color.ColorWeight + +trait AlertComponentsModule: + self: IconsModule => + object alerts: + // TODO: use context functions and builder pattern to build the alerts + def alert( + message: HtmlMod, + icon: SvgElement, + color: ColorKind, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + div( + cls := "rounded-md p-4", + color(50).bg, + div( + cls := "flex", + icon, + div( + cls := "ml-3", + title.map(t => + h3( + cls := "text-sm font-medium", + color(800).text, + t + ) + ), + div( + cls := "text-sm", + title.map(_ => cls("mt-2")), + color(700).text, + message + ), + actions.map { act => + div( + cls := "mt-4", + act(color) + ) + } + ), + mods(color) + ) + ) + + def warning( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-warning`(), + ColorKind.yellow, + title = title, + actions = actions, + mods = mods + ) + + def error( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-error`(), + ColorKind.red, + title = title, + actions = actions, + mods = mods + ) + + def success( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-success`(), + ColorKind.green, + title = title, + actions = actions, + mods = mods + ) + + def info( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-info`(), + ColorKind.blue, + title = title, + actions = actions, + mods = mods + ) + + def buttons(buttons: (ColorKind => HtmlMod)*): ColorKind => HtmlMod = + color => + div( + cls("-mx-2 -my-1.5 flex"), + buttons.map(_(color)) + ) + + def button(title: String)(mods: HtmlMod*): ColorKind => HtmlMod = color => + L.button( + tpe("button"), + cls( + "first:ml-0 ml-3 rounded-md px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(800).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + title, + mods + ) + + def closeMod(mods: HtmlMod): ColorKind => HtmlMod = color => + val closeIcon: SvgElement = + import svg.* + svg( + cls := "h-5 w-5", + viewBox := "0 0 20 20", + fill := "currentColor", + path( + d := "M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" + ) + ) + div( + cls("ml-auto pl-3"), + L.button( + cls( + "-mx-1.5 -my-1.5 inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(500).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + mods, + aria.hidden := true, + closeIcon + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala new file mode 100644 index 0000000..fa374fe --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala @@ -0,0 +1,52 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait AppShellComponentsModule: + object shell: + object stackedLight extends StackedAppShell: + override def navCls = cls("border-b border-gray-200 bg-white") + override def headerCls = emptyMod + override def pageWrapper(content: HtmlMod*): HtmlMod = + div(cls("py-10"), content) + + object stackedBranded extends StackedAppShell: + override def navCls = cls("bg-indigo-600") + override def headerCls = cls("bg-white shadown-sm") + override def pageWrapper(content: HtmlMod*): HtmlMod = content + +trait StackedAppShell: + protected def navCls: HtmlMod + protected def headerCls: HtmlMod + protected def pageWrapper(content: HtmlMod*): HtmlMod + + def apply(pageTitle: HtmlMod)(navbarItems: HtmlMod*)( + content: HtmlMod* + ): HtmlElement = + div( + cls("min-h-full"), + navTag( + navCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + div(cls("flex h-16 items-center justify-between"), navbarItems) + ) + ), + pageWrapper( + headerTag( + headerCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + h1( + cls( + "text-3xl font-bold leading-tight tracking-tight text-gray-900" + ), + pageTitle + ) + ) + ), + mainTag( + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala new file mode 100644 index 0000000..f6d7d7f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait BadgeComponentsModule: + + object badges: + def pill(name: String, color: ColorKind): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + name + ) + + def pill(name: String, color: Signal[ColorKind]): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + cls <-- color.map(_.apply(800).text.toCSS), + cls <-- color.map(_.apply(100).bg.toCSS), + name + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala new file mode 100644 index 0000000..03a2c9c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait BreadcrumbsModule: + self: IconsModule => + + object breadcrumbs: + def container(mods: HtmlMod*): HtmlElement = + navTag(cls("flex"), aria.label("Breadcrumb"), mods) + + def list(mods: HtmlMod*): HtmlElement = + ol(cls("flex items-center space-x-4"), role("list"), mods) + + def homeItem(mods: HtmlMod*): HtmlElement = + li( + div( + a( + href("#"), + cls("text-gray-400 hover:text-gray-500"), + icons.home(svg.cls("h-5 w-5 flex-shrink-0")), + span(cls("sr-only"), "Home"), + mods + ) + ) + ) + + def item(mods: HtmlMod*): HtmlElement = + li( + div( + cls("flex items-center"), + icons.`chevron-right-solid`( + svg.cls("h-5 w-5 flex-shrink-0 text-gray-400") + ), + a( + href("#"), + cls("ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala new file mode 100644 index 0000000..27f3cf2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala @@ -0,0 +1,102 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +trait ButtonComponentsModule: + + object buttons: + + private inline def srHelp(text: String): Modifier[HtmlElement] = + span(cls := "sr-only", text) + + val sharedButtonClasses = + "inline-flex justify-center py-2 px-4 border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + + val sharedButtonMod: HtmlMod = cls(sharedButtonClasses) + + val primaryButtonClasses = + "border-transparent text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300" + val primaryButtonMod: HtmlMod = cls(primaryButtonClasses) + + val secondaryButtonClasses = + "border-gray-300 text-gray-700 hover:bg-gray-50" + val secondaryButtonMod: HtmlMod = cls(secondaryButtonClasses) + + val neutralButtonClasses = + "border-gray-300 text-gray-700 bg-white hover:bg-gray-50" + val neutralButtonMod: HtmlMod = cls(neutralButtonClasses) + + val positiveButtonClasses = + "border-transparent text-white bg-green-600 hover:bg-green-700" + val positiveButtonMod: HtmlMod = cls(positiveButtonClasses) + + val negativeButtonClasses = + "border-transparent text-white bg-red-600 hover:bg-red-700" + val negativeButtonMod: HtmlMod = cls(negativeButtonClasses) + + def button( + text: Modifier[HtmlElement], + id: Option[String], + icon: Option[SvgElement] = None, + buttonType: String = "submit", + primary: Boolean = false + )(mods: HtmlMod*): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe(buttonType), + sharedButtonMod, + if primary then primaryButtonMod else secondaryButtonMod, + icon, + text, + mods + ) + + def primaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "submit" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, true)(mods*) + + def secondaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "button" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, false)(mods*) + + def inlineButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srText.map(srHelp(_)), + icon + ) + + def iconButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srText.map(srHelp(_)), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala new file mode 100644 index 0000000..ae4ab86 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala @@ -0,0 +1,56 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +import io.laminext.syntax.core.* + +trait ComboboxModule: + self: IconsModule => + + object combobox: + object simple: + def container(mods: HtmlMod*): Div = div(cls("relative mt-2"), mods) + + def button(mods: HtmlMod*): Button = + L.button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + mods + ) + + def options(mods: HtmlMod*) = + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + role := "listbox", + mods + ) + + def option(isActive: Signal[Boolean], isSelected: Signal[Boolean])( + mods: HtmlMod* + ) = + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- isActive, + cls.toggle("text-gray-900") <-- isActive.not, + cls.toggle("font-semibold") <-- isSelected, + mods + ) + + def optionValue(l: String): HtmlElement = + span(cls("block truncate"), l) + + def checkmark(isActive: Signal[Boolean]): HtmlElement = + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- isActive.not, + cls.toggle("text-white") <-- isActive, + icons.check(svg.cls("h-5 w-5")) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala new file mode 100644 index 0000000..f5a8873 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ContainerComponentsModule: + object container: + /** Full-width on mobile, constrained with padded content above */ + def default(content: HtmlMod*): HtmlElement = + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + + def padded(content: HtmlMod*): HtmlElement = + default(cls("px-4"), content) + + def narrow(content: HtmlMod*): HtmlElement = + padded( + div(cls("mx-auto max-w-3xl"), content) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala new file mode 100644 index 0000000..d601ab6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala @@ -0,0 +1,117 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.core.FileRef +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.laminar.* +import works.iterative.core.UserMessage + +trait DetailComponentsModule: + self: IconsModule => + object details: + def sectionHeader( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls( + "flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls := "px-4 sm:px-0", + h3( + cls := "text-base font-semibold leading-7 text-gray-900", + title + ), + subtitle.map(st => + p( + cls := "mt-1 max-w-2xl text-sm leading-6 text-gray-500", + st + ) + ) + ), + div(cls("flex-shrink-0"), actions) + ) + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + )(content: HtmlMod): HtmlElement = + div(sectionHeader(title, subtitle, actions), content) + + def fields(items: Node*): HtmlElement = + div( + dl( + cls := "divide-y divide-gray-100", + items + ) + ) + + def field( + title: Node, + content: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "px-2 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0", + dt( + cls := "text-sm font-medium leading-6 text-gray-900", + title + ), + dd( + cls := "mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0", + content + ) + ) + + def files( + fs: Seq[FileRef], + fileMods: Option[(FileRef, Int) => HtmlMod] = None + )( + mods: HtmlMod* + )(using ComponentContext[?]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-100 rounded-md border border-gray-200", + fs.zipWithIndex + .map((f, i) => + fileMods match + case Some(fm) => file(f)(fm(f, i)) + case _ => file(f)() + ), + mods + ) + + def file(f: FileRef)(mods: HtmlMod*)(using + ComponentContext[?] + ): HtmlElement = + li( + cls := "flex items-center justify-between py-4 pl-4 pr-5 text-sm leading-6", + div( + cls := "flex w-0 flex-1 items-center", + icons.`paper-clip-solid`(), + div( + cls := "ml-4 flex min-w-0 flex-1 gap-2", + span( + cls := "truncate font-medium", + f.name + ), + f.sizeString.map(size => + span(cls := "flex-shrink-0 text-gray-400", size) + ) + ) + ), + div( + cls := "ml-4 flex-shrink-0", + a( + href := f.url, + target := "_blank", + cls := "font-medium text-indigo-600 hover:text-indigo-500", + UserMessage("file.download").asString + ) + ), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala new file mode 100644 index 0000000..e9d7f1b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala @@ -0,0 +1,202 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html +import io.laminext.syntax.core.* + +trait FormComponentsModule: + self: IconsModule => + + object forms: + + val inputClasses = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls("space-y-6 sm:space-y-5"), + div( + h3(cls("text-lg leading-6 font-medium text-gray-900"), title), + subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) + ), + div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) + ) + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: Modifier[HtmlElement]* + ): ReactiveHtmlElement[html.Label] = + L.label( + cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), + forId.map(id => L.forId(id)), + labelText, + if required then sup(cls("text-gray-400"), "* povinné pole") + else emptyMod, + mods + ) + + def field( + label: Modifier[HtmlElement] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls( + "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5" + ), + label, + div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) + ) + + def field( + id: String, + labelText: String, + input: HtmlElement, + help: Option[String] + ): HtmlElement = + field( + label(labelText, Some(id))() + )( + input.amend(idAttr(id)), + help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) + ) + + def form(mods: Modifier[HtmlElement]*)( + sections: Modifier[HtmlElement]* + )(actions: Modifier[HtmlElement]*): ReactiveHtmlElement[html.Form] = + L.form( + cls("space-y-8 divide-y divide-gray-200"), + mods, + sections, + div( + cls("pt-5"), + div(cls("flex justify-end"), actions) + ) + ) + + def inlineForm( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + def errorTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-red-600") + + def validationError(text: Modifier[HtmlElement]): HtmlElement = + p(errorTextMods, text) + + def helpTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-gray-500") + + def fieldHelp(text: Modifier[HtmlElement]): HtmlElement = + p(helpTextMods, text) + + def inputField( + id: String, + labelText: String, + placeholderText: Option[String] = None, + inputType: String = "text", + helpText: Option[String] = None + ): HtmlElement = + field( + id, + labelText, + input(id, inputType, placeholderText)(), + helpText + ) + + def input( + id: String, + inputType: String = "text", + placeholderText: Option[String] = None + )(mods: HtmlMod*): HtmlElement = + L.input( + cls(inputClasses), + idAttr(id), + nameAttr(id), + placeholderText.map(placeholder(_)), + tpe(inputType), + mods + ) + + def comboBoxSimple( + options: List[(String, String)], + selectedInitially: Option[String] = None, + id: Option[String] = None, + name: Option[String] = None + ): HtmlElement = + val expanded = Var(false) + val selected = Var(selectedInitially) + div( + cls("relative mt-2"), + L.input( + cls( + "w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + id.map(idAttr(_)), + name.map(nameAttr(_)), + tpe("text"), + role("combobox"), + aria.controls("options"), + aria.expanded <-- expanded + ), + button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + onClick.preventDefault --> (_ => expanded.toggle()) + ), + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + cls.toggle("hidden") <-- expanded.signal.not, + id.map(i => idAttr(s"${i}-options")), + role := "listbox", + // Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation. + // Active: "text-white bg-indigo-600", Not Active: "text-gray-900" + for (((v, l), i) <- options.zipWithIndex) + yield + val active = Var(false) + val isSelected = selected.signal.map(_.contains(v)) + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- active.signal, + cls.toggle("text-gray-900") <-- active.signal.not, + cls.toggle("font-semibold") <-- isSelected, + id.map(cid => idAttr(s"${cid}-option-${i}")), + role := "option", + tabIndex := -1, + // Selected: "font-semibold" + span(cls("block truncate"), l), + // Checkmark, only display for selected option. + // Active: "text-white", Not Active: "text-indigo-600" + isSelected.childWhenTrue( + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- active.signal.not, + cls.toggle("text-white") <-- active.signal, + icons.check(svg.cls("h-5 w-5")) + ) + ), + onClick.preventDefault.mapTo( + v + ) --> selected.writer.contramapSome, + onMouseEnter --> (_ => active.set(true)), + onMouseLeave --> (_ => active.set(false)) + ) + // More items... + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala new file mode 100644 index 0000000..6a44cce --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait HeadingsComponentsModule: + object headings: + def section(title: Node, actions: HtmlElement*): HtmlElement = + div( + cls("border-b border-gray-200 pb-5"), + div( + cls("sm:flex sm:items-center sm:justify-between"), + h3(cls("text-base font-semibold leading-6 text-gray-900"), title), + div( + cls("mt-3 flex sm:ml-4 sm:mt-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) + + def sectionWithSubtitle( + title: Node, + subtitle: Node, + actions: HtmlElement* + ): HtmlElement = + div( + cls("border-b border-gray-200 bg-white px-4 py-5 sm:px-6"), + div( + cls( + "-ml-4 -mt-4 flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls("ml-4 mt-4"), + h3( + cls("text-base font-semibold leading-6 text-gray-900"), + title + ), + p( + cls("mt-1 text-sm text-gray-500"), + subtitle + ) + ), + div( + cls("ml-4 mt-4 flex-shrink-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala new file mode 100644 index 0000000..d2bd069 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala @@ -0,0 +1,160 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind +import works.iterative.ui.components.laminar.tailwind.color.ColorWeight + +trait AlertComponentsModule: + self: IconsModule => + object alerts: + // TODO: use context functions and builder pattern to build the alerts + def alert( + message: HtmlMod, + icon: SvgElement, + color: ColorKind, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + div( + cls := "rounded-md p-4", + color(50).bg, + div( + cls := "flex", + icon, + div( + cls := "ml-3", + title.map(t => + h3( + cls := "text-sm font-medium", + color(800).text, + t + ) + ), + div( + cls := "text-sm", + title.map(_ => cls("mt-2")), + color(700).text, + message + ), + actions.map { act => + div( + cls := "mt-4", + act(color) + ) + } + ), + mods(color) + ) + ) + + def warning( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-warning`(), + ColorKind.yellow, + title = title, + actions = actions, + mods = mods + ) + + def error( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-error`(), + ColorKind.red, + title = title, + actions = actions, + mods = mods + ) + + def success( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-success`(), + ColorKind.green, + title = title, + actions = actions, + mods = mods + ) + + def info( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-info`(), + ColorKind.blue, + title = title, + actions = actions, + mods = mods + ) + + def buttons(buttons: (ColorKind => HtmlMod)*): ColorKind => HtmlMod = + color => + div( + cls("-mx-2 -my-1.5 flex"), + buttons.map(_(color)) + ) + + def button(title: String)(mods: HtmlMod*): ColorKind => HtmlMod = color => + L.button( + tpe("button"), + cls( + "first:ml-0 ml-3 rounded-md px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(800).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + title, + mods + ) + + def closeMod(mods: HtmlMod): ColorKind => HtmlMod = color => + val closeIcon: SvgElement = + import svg.* + svg( + cls := "h-5 w-5", + viewBox := "0 0 20 20", + fill := "currentColor", + path( + d := "M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" + ) + ) + div( + cls("ml-auto pl-3"), + L.button( + cls( + "-mx-1.5 -my-1.5 inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(500).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + mods, + aria.hidden := true, + closeIcon + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala new file mode 100644 index 0000000..fa374fe --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala @@ -0,0 +1,52 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait AppShellComponentsModule: + object shell: + object stackedLight extends StackedAppShell: + override def navCls = cls("border-b border-gray-200 bg-white") + override def headerCls = emptyMod + override def pageWrapper(content: HtmlMod*): HtmlMod = + div(cls("py-10"), content) + + object stackedBranded extends StackedAppShell: + override def navCls = cls("bg-indigo-600") + override def headerCls = cls("bg-white shadown-sm") + override def pageWrapper(content: HtmlMod*): HtmlMod = content + +trait StackedAppShell: + protected def navCls: HtmlMod + protected def headerCls: HtmlMod + protected def pageWrapper(content: HtmlMod*): HtmlMod + + def apply(pageTitle: HtmlMod)(navbarItems: HtmlMod*)( + content: HtmlMod* + ): HtmlElement = + div( + cls("min-h-full"), + navTag( + navCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + div(cls("flex h-16 items-center justify-between"), navbarItems) + ) + ), + pageWrapper( + headerTag( + headerCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + h1( + cls( + "text-3xl font-bold leading-tight tracking-tight text-gray-900" + ), + pageTitle + ) + ) + ), + mainTag( + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala new file mode 100644 index 0000000..f6d7d7f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait BadgeComponentsModule: + + object badges: + def pill(name: String, color: ColorKind): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + name + ) + + def pill(name: String, color: Signal[ColorKind]): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + cls <-- color.map(_.apply(800).text.toCSS), + cls <-- color.map(_.apply(100).bg.toCSS), + name + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala new file mode 100644 index 0000000..03a2c9c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait BreadcrumbsModule: + self: IconsModule => + + object breadcrumbs: + def container(mods: HtmlMod*): HtmlElement = + navTag(cls("flex"), aria.label("Breadcrumb"), mods) + + def list(mods: HtmlMod*): HtmlElement = + ol(cls("flex items-center space-x-4"), role("list"), mods) + + def homeItem(mods: HtmlMod*): HtmlElement = + li( + div( + a( + href("#"), + cls("text-gray-400 hover:text-gray-500"), + icons.home(svg.cls("h-5 w-5 flex-shrink-0")), + span(cls("sr-only"), "Home"), + mods + ) + ) + ) + + def item(mods: HtmlMod*): HtmlElement = + li( + div( + cls("flex items-center"), + icons.`chevron-right-solid`( + svg.cls("h-5 w-5 flex-shrink-0 text-gray-400") + ), + a( + href("#"), + cls("ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala new file mode 100644 index 0000000..27f3cf2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala @@ -0,0 +1,102 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +trait ButtonComponentsModule: + + object buttons: + + private inline def srHelp(text: String): Modifier[HtmlElement] = + span(cls := "sr-only", text) + + val sharedButtonClasses = + "inline-flex justify-center py-2 px-4 border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + + val sharedButtonMod: HtmlMod = cls(sharedButtonClasses) + + val primaryButtonClasses = + "border-transparent text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300" + val primaryButtonMod: HtmlMod = cls(primaryButtonClasses) + + val secondaryButtonClasses = + "border-gray-300 text-gray-700 hover:bg-gray-50" + val secondaryButtonMod: HtmlMod = cls(secondaryButtonClasses) + + val neutralButtonClasses = + "border-gray-300 text-gray-700 bg-white hover:bg-gray-50" + val neutralButtonMod: HtmlMod = cls(neutralButtonClasses) + + val positiveButtonClasses = + "border-transparent text-white bg-green-600 hover:bg-green-700" + val positiveButtonMod: HtmlMod = cls(positiveButtonClasses) + + val negativeButtonClasses = + "border-transparent text-white bg-red-600 hover:bg-red-700" + val negativeButtonMod: HtmlMod = cls(negativeButtonClasses) + + def button( + text: Modifier[HtmlElement], + id: Option[String], + icon: Option[SvgElement] = None, + buttonType: String = "submit", + primary: Boolean = false + )(mods: HtmlMod*): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe(buttonType), + sharedButtonMod, + if primary then primaryButtonMod else secondaryButtonMod, + icon, + text, + mods + ) + + def primaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "submit" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, true)(mods*) + + def secondaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "button" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, false)(mods*) + + def inlineButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srText.map(srHelp(_)), + icon + ) + + def iconButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srText.map(srHelp(_)), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala new file mode 100644 index 0000000..ae4ab86 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala @@ -0,0 +1,56 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +import io.laminext.syntax.core.* + +trait ComboboxModule: + self: IconsModule => + + object combobox: + object simple: + def container(mods: HtmlMod*): Div = div(cls("relative mt-2"), mods) + + def button(mods: HtmlMod*): Button = + L.button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + mods + ) + + def options(mods: HtmlMod*) = + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + role := "listbox", + mods + ) + + def option(isActive: Signal[Boolean], isSelected: Signal[Boolean])( + mods: HtmlMod* + ) = + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- isActive, + cls.toggle("text-gray-900") <-- isActive.not, + cls.toggle("font-semibold") <-- isSelected, + mods + ) + + def optionValue(l: String): HtmlElement = + span(cls("block truncate"), l) + + def checkmark(isActive: Signal[Boolean]): HtmlElement = + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- isActive.not, + cls.toggle("text-white") <-- isActive, + icons.check(svg.cls("h-5 w-5")) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala new file mode 100644 index 0000000..f5a8873 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ContainerComponentsModule: + object container: + /** Full-width on mobile, constrained with padded content above */ + def default(content: HtmlMod*): HtmlElement = + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + + def padded(content: HtmlMod*): HtmlElement = + default(cls("px-4"), content) + + def narrow(content: HtmlMod*): HtmlElement = + padded( + div(cls("mx-auto max-w-3xl"), content) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala new file mode 100644 index 0000000..d601ab6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala @@ -0,0 +1,117 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.core.FileRef +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.laminar.* +import works.iterative.core.UserMessage + +trait DetailComponentsModule: + self: IconsModule => + object details: + def sectionHeader( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls( + "flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls := "px-4 sm:px-0", + h3( + cls := "text-base font-semibold leading-7 text-gray-900", + title + ), + subtitle.map(st => + p( + cls := "mt-1 max-w-2xl text-sm leading-6 text-gray-500", + st + ) + ) + ), + div(cls("flex-shrink-0"), actions) + ) + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + )(content: HtmlMod): HtmlElement = + div(sectionHeader(title, subtitle, actions), content) + + def fields(items: Node*): HtmlElement = + div( + dl( + cls := "divide-y divide-gray-100", + items + ) + ) + + def field( + title: Node, + content: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "px-2 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0", + dt( + cls := "text-sm font-medium leading-6 text-gray-900", + title + ), + dd( + cls := "mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0", + content + ) + ) + + def files( + fs: Seq[FileRef], + fileMods: Option[(FileRef, Int) => HtmlMod] = None + )( + mods: HtmlMod* + )(using ComponentContext[?]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-100 rounded-md border border-gray-200", + fs.zipWithIndex + .map((f, i) => + fileMods match + case Some(fm) => file(f)(fm(f, i)) + case _ => file(f)() + ), + mods + ) + + def file(f: FileRef)(mods: HtmlMod*)(using + ComponentContext[?] + ): HtmlElement = + li( + cls := "flex items-center justify-between py-4 pl-4 pr-5 text-sm leading-6", + div( + cls := "flex w-0 flex-1 items-center", + icons.`paper-clip-solid`(), + div( + cls := "ml-4 flex min-w-0 flex-1 gap-2", + span( + cls := "truncate font-medium", + f.name + ), + f.sizeString.map(size => + span(cls := "flex-shrink-0 text-gray-400", size) + ) + ) + ), + div( + cls := "ml-4 flex-shrink-0", + a( + href := f.url, + target := "_blank", + cls := "font-medium text-indigo-600 hover:text-indigo-500", + UserMessage("file.download").asString + ) + ), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala new file mode 100644 index 0000000..e9d7f1b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala @@ -0,0 +1,202 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html +import io.laminext.syntax.core.* + +trait FormComponentsModule: + self: IconsModule => + + object forms: + + val inputClasses = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls("space-y-6 sm:space-y-5"), + div( + h3(cls("text-lg leading-6 font-medium text-gray-900"), title), + subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) + ), + div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) + ) + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: Modifier[HtmlElement]* + ): ReactiveHtmlElement[html.Label] = + L.label( + cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), + forId.map(id => L.forId(id)), + labelText, + if required then sup(cls("text-gray-400"), "* povinné pole") + else emptyMod, + mods + ) + + def field( + label: Modifier[HtmlElement] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls( + "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5" + ), + label, + div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) + ) + + def field( + id: String, + labelText: String, + input: HtmlElement, + help: Option[String] + ): HtmlElement = + field( + label(labelText, Some(id))() + )( + input.amend(idAttr(id)), + help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) + ) + + def form(mods: Modifier[HtmlElement]*)( + sections: Modifier[HtmlElement]* + )(actions: Modifier[HtmlElement]*): ReactiveHtmlElement[html.Form] = + L.form( + cls("space-y-8 divide-y divide-gray-200"), + mods, + sections, + div( + cls("pt-5"), + div(cls("flex justify-end"), actions) + ) + ) + + def inlineForm( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + def errorTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-red-600") + + def validationError(text: Modifier[HtmlElement]): HtmlElement = + p(errorTextMods, text) + + def helpTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-gray-500") + + def fieldHelp(text: Modifier[HtmlElement]): HtmlElement = + p(helpTextMods, text) + + def inputField( + id: String, + labelText: String, + placeholderText: Option[String] = None, + inputType: String = "text", + helpText: Option[String] = None + ): HtmlElement = + field( + id, + labelText, + input(id, inputType, placeholderText)(), + helpText + ) + + def input( + id: String, + inputType: String = "text", + placeholderText: Option[String] = None + )(mods: HtmlMod*): HtmlElement = + L.input( + cls(inputClasses), + idAttr(id), + nameAttr(id), + placeholderText.map(placeholder(_)), + tpe(inputType), + mods + ) + + def comboBoxSimple( + options: List[(String, String)], + selectedInitially: Option[String] = None, + id: Option[String] = None, + name: Option[String] = None + ): HtmlElement = + val expanded = Var(false) + val selected = Var(selectedInitially) + div( + cls("relative mt-2"), + L.input( + cls( + "w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + id.map(idAttr(_)), + name.map(nameAttr(_)), + tpe("text"), + role("combobox"), + aria.controls("options"), + aria.expanded <-- expanded + ), + button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + onClick.preventDefault --> (_ => expanded.toggle()) + ), + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + cls.toggle("hidden") <-- expanded.signal.not, + id.map(i => idAttr(s"${i}-options")), + role := "listbox", + // Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation. + // Active: "text-white bg-indigo-600", Not Active: "text-gray-900" + for (((v, l), i) <- options.zipWithIndex) + yield + val active = Var(false) + val isSelected = selected.signal.map(_.contains(v)) + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- active.signal, + cls.toggle("text-gray-900") <-- active.signal.not, + cls.toggle("font-semibold") <-- isSelected, + id.map(cid => idAttr(s"${cid}-option-${i}")), + role := "option", + tabIndex := -1, + // Selected: "font-semibold" + span(cls("block truncate"), l), + // Checkmark, only display for selected option. + // Active: "text-white", Not Active: "text-indigo-600" + isSelected.childWhenTrue( + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- active.signal.not, + cls.toggle("text-white") <-- active.signal, + icons.check(svg.cls("h-5 w-5")) + ) + ), + onClick.preventDefault.mapTo( + v + ) --> selected.writer.contramapSome, + onMouseEnter --> (_ => active.set(true)), + onMouseLeave --> (_ => active.set(false)) + ) + // More items... + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala new file mode 100644 index 0000000..6a44cce --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait HeadingsComponentsModule: + object headings: + def section(title: Node, actions: HtmlElement*): HtmlElement = + div( + cls("border-b border-gray-200 pb-5"), + div( + cls("sm:flex sm:items-center sm:justify-between"), + h3(cls("text-base font-semibold leading-6 text-gray-900"), title), + div( + cls("mt-3 flex sm:ml-4 sm:mt-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) + + def sectionWithSubtitle( + title: Node, + subtitle: Node, + actions: HtmlElement* + ): HtmlElement = + div( + cls("border-b border-gray-200 bg-white px-4 py-5 sm:px-6"), + div( + cls( + "-ml-4 -mt-4 flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls("ml-4 mt-4"), + h3( + cls("text-base font-semibold leading-6 text-gray-900"), + title + ), + p( + cls("mt-1 text-sm text-gray-500"), + subtitle + ) + ), + div( + cls("ml-4 mt-4 flex-shrink-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala new file mode 100644 index 0000000..be16e48 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala @@ -0,0 +1,321 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.codecs +import works.iterative.ui.components.laminar.CustomAttrs + +trait IconsModule: + object icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + def upload(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 24 24"), + path(d := "M0 0h24v24H0z", fill("none")), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + + def home(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 20 20"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + clipRule := "evenodd", + fillRule := "evenodd", + d := "M9.293 2.293a1 1 0 011.414 0l7 7A1 1 0 0117 11h-1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-3a1 1 0 00-1-1H9a1 1 0 00-1 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-6H3a1 1 0 01-.707-1.707l7-7z" + ) + ) + + def `x-mark-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + def `arrow-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18") + ) + ) + + def `chevron-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M15.75 19.5L8.25 12l7.5-7.5") + ) + ) + + def `chevron-right-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + clipRule("evenodd"), + fillRule("evenodd"), + d( + "M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" + ) + ) + ) + + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) + + def `paper-clip-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + cls := "flex-shrink-0 text-gray-400", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M15.621 4.379a3 3 0 00-4.242 0l-7 7a3 3 0 004.241 4.243h.001l.497-.5a.75.75 0 011.064 1.057l-.498.501-.002.002a4.5 4.5 0 01-6.364-6.364l7-7a4.5 4.5 0 016.368 6.36l-3.455 3.553A2.625 2.625 0 119.52 9.52l3.45-3.451a.75.75 0 111.061 1.06l-3.45 3.451a1.125 1.125 0 001.587 1.595l3.454-3.553a3 3 0 000-4.242z", + clipRule := "evenodd" + ) + ) + + def `exclamation-circle-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-warning`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-yellow-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-error`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-red-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z", + clipRule := "evenodd" + ) + ) + + def `alert-success`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-green-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z", + clipRule := "evenodd" + ) + ) + + def `alert-info`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-blue-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z", + clipRule := "evenodd" + ) + ) + + def spinner(mods: SvgMod*): SvgElement = svg( + withDefault(mods, cls("h-4 w-4")), + svgAttr("role", codecs.StringAsIsCodec, None) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + def arrowPath(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + fill("none"), + stroke("currentColor"), + strokeWidth("1.5"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d( + "M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" + ) + ) + ) + + def chevronUpDown(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z", + clipRule := "evenodd" + ) + ) + + def check(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z", + clipRule := "evenodd" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala new file mode 100644 index 0000000..d2bd069 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala @@ -0,0 +1,160 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind +import works.iterative.ui.components.laminar.tailwind.color.ColorWeight + +trait AlertComponentsModule: + self: IconsModule => + object alerts: + // TODO: use context functions and builder pattern to build the alerts + def alert( + message: HtmlMod, + icon: SvgElement, + color: ColorKind, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + div( + cls := "rounded-md p-4", + color(50).bg, + div( + cls := "flex", + icon, + div( + cls := "ml-3", + title.map(t => + h3( + cls := "text-sm font-medium", + color(800).text, + t + ) + ), + div( + cls := "text-sm", + title.map(_ => cls("mt-2")), + color(700).text, + message + ), + actions.map { act => + div( + cls := "mt-4", + act(color) + ) + } + ), + mods(color) + ) + ) + + def warning( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-warning`(), + ColorKind.yellow, + title = title, + actions = actions, + mods = mods + ) + + def error( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-error`(), + ColorKind.red, + title = title, + actions = actions, + mods = mods + ) + + def success( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-success`(), + ColorKind.green, + title = title, + actions = actions, + mods = mods + ) + + def info( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-info`(), + ColorKind.blue, + title = title, + actions = actions, + mods = mods + ) + + def buttons(buttons: (ColorKind => HtmlMod)*): ColorKind => HtmlMod = + color => + div( + cls("-mx-2 -my-1.5 flex"), + buttons.map(_(color)) + ) + + def button(title: String)(mods: HtmlMod*): ColorKind => HtmlMod = color => + L.button( + tpe("button"), + cls( + "first:ml-0 ml-3 rounded-md px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(800).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + title, + mods + ) + + def closeMod(mods: HtmlMod): ColorKind => HtmlMod = color => + val closeIcon: SvgElement = + import svg.* + svg( + cls := "h-5 w-5", + viewBox := "0 0 20 20", + fill := "currentColor", + path( + d := "M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" + ) + ) + div( + cls("ml-auto pl-3"), + L.button( + cls( + "-mx-1.5 -my-1.5 inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(500).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + mods, + aria.hidden := true, + closeIcon + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala new file mode 100644 index 0000000..fa374fe --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala @@ -0,0 +1,52 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait AppShellComponentsModule: + object shell: + object stackedLight extends StackedAppShell: + override def navCls = cls("border-b border-gray-200 bg-white") + override def headerCls = emptyMod + override def pageWrapper(content: HtmlMod*): HtmlMod = + div(cls("py-10"), content) + + object stackedBranded extends StackedAppShell: + override def navCls = cls("bg-indigo-600") + override def headerCls = cls("bg-white shadown-sm") + override def pageWrapper(content: HtmlMod*): HtmlMod = content + +trait StackedAppShell: + protected def navCls: HtmlMod + protected def headerCls: HtmlMod + protected def pageWrapper(content: HtmlMod*): HtmlMod + + def apply(pageTitle: HtmlMod)(navbarItems: HtmlMod*)( + content: HtmlMod* + ): HtmlElement = + div( + cls("min-h-full"), + navTag( + navCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + div(cls("flex h-16 items-center justify-between"), navbarItems) + ) + ), + pageWrapper( + headerTag( + headerCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + h1( + cls( + "text-3xl font-bold leading-tight tracking-tight text-gray-900" + ), + pageTitle + ) + ) + ), + mainTag( + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala new file mode 100644 index 0000000..f6d7d7f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait BadgeComponentsModule: + + object badges: + def pill(name: String, color: ColorKind): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + name + ) + + def pill(name: String, color: Signal[ColorKind]): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + cls <-- color.map(_.apply(800).text.toCSS), + cls <-- color.map(_.apply(100).bg.toCSS), + name + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala new file mode 100644 index 0000000..03a2c9c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait BreadcrumbsModule: + self: IconsModule => + + object breadcrumbs: + def container(mods: HtmlMod*): HtmlElement = + navTag(cls("flex"), aria.label("Breadcrumb"), mods) + + def list(mods: HtmlMod*): HtmlElement = + ol(cls("flex items-center space-x-4"), role("list"), mods) + + def homeItem(mods: HtmlMod*): HtmlElement = + li( + div( + a( + href("#"), + cls("text-gray-400 hover:text-gray-500"), + icons.home(svg.cls("h-5 w-5 flex-shrink-0")), + span(cls("sr-only"), "Home"), + mods + ) + ) + ) + + def item(mods: HtmlMod*): HtmlElement = + li( + div( + cls("flex items-center"), + icons.`chevron-right-solid`( + svg.cls("h-5 w-5 flex-shrink-0 text-gray-400") + ), + a( + href("#"), + cls("ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala new file mode 100644 index 0000000..27f3cf2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala @@ -0,0 +1,102 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +trait ButtonComponentsModule: + + object buttons: + + private inline def srHelp(text: String): Modifier[HtmlElement] = + span(cls := "sr-only", text) + + val sharedButtonClasses = + "inline-flex justify-center py-2 px-4 border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + + val sharedButtonMod: HtmlMod = cls(sharedButtonClasses) + + val primaryButtonClasses = + "border-transparent text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300" + val primaryButtonMod: HtmlMod = cls(primaryButtonClasses) + + val secondaryButtonClasses = + "border-gray-300 text-gray-700 hover:bg-gray-50" + val secondaryButtonMod: HtmlMod = cls(secondaryButtonClasses) + + val neutralButtonClasses = + "border-gray-300 text-gray-700 bg-white hover:bg-gray-50" + val neutralButtonMod: HtmlMod = cls(neutralButtonClasses) + + val positiveButtonClasses = + "border-transparent text-white bg-green-600 hover:bg-green-700" + val positiveButtonMod: HtmlMod = cls(positiveButtonClasses) + + val negativeButtonClasses = + "border-transparent text-white bg-red-600 hover:bg-red-700" + val negativeButtonMod: HtmlMod = cls(negativeButtonClasses) + + def button( + text: Modifier[HtmlElement], + id: Option[String], + icon: Option[SvgElement] = None, + buttonType: String = "submit", + primary: Boolean = false + )(mods: HtmlMod*): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe(buttonType), + sharedButtonMod, + if primary then primaryButtonMod else secondaryButtonMod, + icon, + text, + mods + ) + + def primaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "submit" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, true)(mods*) + + def secondaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "button" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, false)(mods*) + + def inlineButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srText.map(srHelp(_)), + icon + ) + + def iconButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srText.map(srHelp(_)), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala new file mode 100644 index 0000000..ae4ab86 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala @@ -0,0 +1,56 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +import io.laminext.syntax.core.* + +trait ComboboxModule: + self: IconsModule => + + object combobox: + object simple: + def container(mods: HtmlMod*): Div = div(cls("relative mt-2"), mods) + + def button(mods: HtmlMod*): Button = + L.button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + mods + ) + + def options(mods: HtmlMod*) = + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + role := "listbox", + mods + ) + + def option(isActive: Signal[Boolean], isSelected: Signal[Boolean])( + mods: HtmlMod* + ) = + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- isActive, + cls.toggle("text-gray-900") <-- isActive.not, + cls.toggle("font-semibold") <-- isSelected, + mods + ) + + def optionValue(l: String): HtmlElement = + span(cls("block truncate"), l) + + def checkmark(isActive: Signal[Boolean]): HtmlElement = + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- isActive.not, + cls.toggle("text-white") <-- isActive, + icons.check(svg.cls("h-5 w-5")) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala new file mode 100644 index 0000000..f5a8873 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ContainerComponentsModule: + object container: + /** Full-width on mobile, constrained with padded content above */ + def default(content: HtmlMod*): HtmlElement = + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + + def padded(content: HtmlMod*): HtmlElement = + default(cls("px-4"), content) + + def narrow(content: HtmlMod*): HtmlElement = + padded( + div(cls("mx-auto max-w-3xl"), content) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala new file mode 100644 index 0000000..d601ab6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala @@ -0,0 +1,117 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.core.FileRef +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.laminar.* +import works.iterative.core.UserMessage + +trait DetailComponentsModule: + self: IconsModule => + object details: + def sectionHeader( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls( + "flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls := "px-4 sm:px-0", + h3( + cls := "text-base font-semibold leading-7 text-gray-900", + title + ), + subtitle.map(st => + p( + cls := "mt-1 max-w-2xl text-sm leading-6 text-gray-500", + st + ) + ) + ), + div(cls("flex-shrink-0"), actions) + ) + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + )(content: HtmlMod): HtmlElement = + div(sectionHeader(title, subtitle, actions), content) + + def fields(items: Node*): HtmlElement = + div( + dl( + cls := "divide-y divide-gray-100", + items + ) + ) + + def field( + title: Node, + content: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "px-2 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0", + dt( + cls := "text-sm font-medium leading-6 text-gray-900", + title + ), + dd( + cls := "mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0", + content + ) + ) + + def files( + fs: Seq[FileRef], + fileMods: Option[(FileRef, Int) => HtmlMod] = None + )( + mods: HtmlMod* + )(using ComponentContext[?]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-100 rounded-md border border-gray-200", + fs.zipWithIndex + .map((f, i) => + fileMods match + case Some(fm) => file(f)(fm(f, i)) + case _ => file(f)() + ), + mods + ) + + def file(f: FileRef)(mods: HtmlMod*)(using + ComponentContext[?] + ): HtmlElement = + li( + cls := "flex items-center justify-between py-4 pl-4 pr-5 text-sm leading-6", + div( + cls := "flex w-0 flex-1 items-center", + icons.`paper-clip-solid`(), + div( + cls := "ml-4 flex min-w-0 flex-1 gap-2", + span( + cls := "truncate font-medium", + f.name + ), + f.sizeString.map(size => + span(cls := "flex-shrink-0 text-gray-400", size) + ) + ) + ), + div( + cls := "ml-4 flex-shrink-0", + a( + href := f.url, + target := "_blank", + cls := "font-medium text-indigo-600 hover:text-indigo-500", + UserMessage("file.download").asString + ) + ), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala new file mode 100644 index 0000000..e9d7f1b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala @@ -0,0 +1,202 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html +import io.laminext.syntax.core.* + +trait FormComponentsModule: + self: IconsModule => + + object forms: + + val inputClasses = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls("space-y-6 sm:space-y-5"), + div( + h3(cls("text-lg leading-6 font-medium text-gray-900"), title), + subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) + ), + div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) + ) + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: Modifier[HtmlElement]* + ): ReactiveHtmlElement[html.Label] = + L.label( + cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), + forId.map(id => L.forId(id)), + labelText, + if required then sup(cls("text-gray-400"), "* povinné pole") + else emptyMod, + mods + ) + + def field( + label: Modifier[HtmlElement] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls( + "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5" + ), + label, + div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) + ) + + def field( + id: String, + labelText: String, + input: HtmlElement, + help: Option[String] + ): HtmlElement = + field( + label(labelText, Some(id))() + )( + input.amend(idAttr(id)), + help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) + ) + + def form(mods: Modifier[HtmlElement]*)( + sections: Modifier[HtmlElement]* + )(actions: Modifier[HtmlElement]*): ReactiveHtmlElement[html.Form] = + L.form( + cls("space-y-8 divide-y divide-gray-200"), + mods, + sections, + div( + cls("pt-5"), + div(cls("flex justify-end"), actions) + ) + ) + + def inlineForm( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + def errorTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-red-600") + + def validationError(text: Modifier[HtmlElement]): HtmlElement = + p(errorTextMods, text) + + def helpTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-gray-500") + + def fieldHelp(text: Modifier[HtmlElement]): HtmlElement = + p(helpTextMods, text) + + def inputField( + id: String, + labelText: String, + placeholderText: Option[String] = None, + inputType: String = "text", + helpText: Option[String] = None + ): HtmlElement = + field( + id, + labelText, + input(id, inputType, placeholderText)(), + helpText + ) + + def input( + id: String, + inputType: String = "text", + placeholderText: Option[String] = None + )(mods: HtmlMod*): HtmlElement = + L.input( + cls(inputClasses), + idAttr(id), + nameAttr(id), + placeholderText.map(placeholder(_)), + tpe(inputType), + mods + ) + + def comboBoxSimple( + options: List[(String, String)], + selectedInitially: Option[String] = None, + id: Option[String] = None, + name: Option[String] = None + ): HtmlElement = + val expanded = Var(false) + val selected = Var(selectedInitially) + div( + cls("relative mt-2"), + L.input( + cls( + "w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + id.map(idAttr(_)), + name.map(nameAttr(_)), + tpe("text"), + role("combobox"), + aria.controls("options"), + aria.expanded <-- expanded + ), + button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + onClick.preventDefault --> (_ => expanded.toggle()) + ), + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + cls.toggle("hidden") <-- expanded.signal.not, + id.map(i => idAttr(s"${i}-options")), + role := "listbox", + // Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation. + // Active: "text-white bg-indigo-600", Not Active: "text-gray-900" + for (((v, l), i) <- options.zipWithIndex) + yield + val active = Var(false) + val isSelected = selected.signal.map(_.contains(v)) + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- active.signal, + cls.toggle("text-gray-900") <-- active.signal.not, + cls.toggle("font-semibold") <-- isSelected, + id.map(cid => idAttr(s"${cid}-option-${i}")), + role := "option", + tabIndex := -1, + // Selected: "font-semibold" + span(cls("block truncate"), l), + // Checkmark, only display for selected option. + // Active: "text-white", Not Active: "text-indigo-600" + isSelected.childWhenTrue( + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- active.signal.not, + cls.toggle("text-white") <-- active.signal, + icons.check(svg.cls("h-5 w-5")) + ) + ), + onClick.preventDefault.mapTo( + v + ) --> selected.writer.contramapSome, + onMouseEnter --> (_ => active.set(true)), + onMouseLeave --> (_ => active.set(false)) + ) + // More items... + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala new file mode 100644 index 0000000..6a44cce --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait HeadingsComponentsModule: + object headings: + def section(title: Node, actions: HtmlElement*): HtmlElement = + div( + cls("border-b border-gray-200 pb-5"), + div( + cls("sm:flex sm:items-center sm:justify-between"), + h3(cls("text-base font-semibold leading-6 text-gray-900"), title), + div( + cls("mt-3 flex sm:ml-4 sm:mt-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) + + def sectionWithSubtitle( + title: Node, + subtitle: Node, + actions: HtmlElement* + ): HtmlElement = + div( + cls("border-b border-gray-200 bg-white px-4 py-5 sm:px-6"), + div( + cls( + "-ml-4 -mt-4 flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls("ml-4 mt-4"), + h3( + cls("text-base font-semibold leading-6 text-gray-900"), + title + ), + p( + cls("mt-1 text-sm text-gray-500"), + subtitle + ) + ), + div( + cls("ml-4 mt-4 flex-shrink-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala new file mode 100644 index 0000000..be16e48 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala @@ -0,0 +1,321 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.codecs +import works.iterative.ui.components.laminar.CustomAttrs + +trait IconsModule: + object icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + def upload(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 24 24"), + path(d := "M0 0h24v24H0z", fill("none")), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + + def home(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 20 20"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + clipRule := "evenodd", + fillRule := "evenodd", + d := "M9.293 2.293a1 1 0 011.414 0l7 7A1 1 0 0117 11h-1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-3a1 1 0 00-1-1H9a1 1 0 00-1 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-6H3a1 1 0 01-.707-1.707l7-7z" + ) + ) + + def `x-mark-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + def `arrow-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18") + ) + ) + + def `chevron-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M15.75 19.5L8.25 12l7.5-7.5") + ) + ) + + def `chevron-right-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + clipRule("evenodd"), + fillRule("evenodd"), + d( + "M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" + ) + ) + ) + + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) + + def `paper-clip-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + cls := "flex-shrink-0 text-gray-400", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M15.621 4.379a3 3 0 00-4.242 0l-7 7a3 3 0 004.241 4.243h.001l.497-.5a.75.75 0 011.064 1.057l-.498.501-.002.002a4.5 4.5 0 01-6.364-6.364l7-7a4.5 4.5 0 016.368 6.36l-3.455 3.553A2.625 2.625 0 119.52 9.52l3.45-3.451a.75.75 0 111.061 1.06l-3.45 3.451a1.125 1.125 0 001.587 1.595l3.454-3.553a3 3 0 000-4.242z", + clipRule := "evenodd" + ) + ) + + def `exclamation-circle-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-warning`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-yellow-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-error`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-red-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z", + clipRule := "evenodd" + ) + ) + + def `alert-success`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-green-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z", + clipRule := "evenodd" + ) + ) + + def `alert-info`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-blue-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z", + clipRule := "evenodd" + ) + ) + + def spinner(mods: SvgMod*): SvgElement = svg( + withDefault(mods, cls("h-4 w-4")), + svgAttr("role", codecs.StringAsIsCodec, None) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + def arrowPath(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + fill("none"), + stroke("currentColor"), + strokeWidth("1.5"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d( + "M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" + ) + ) + ) + + def chevronUpDown(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z", + clipRule := "evenodd" + ) + ) + + def check(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z", + clipRule := "evenodd" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala new file mode 100644 index 0000000..70c4a7c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* + +trait LayoutModule: + object layout: + def cardMod: HtmlMod = cls("bg-white shadow px-4 py-5 sm:rounded-lg sm:p-6") + def card(content: Modifier[HtmlElement]*): HtmlElement = + div(cardMod, content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala new file mode 100644 index 0000000..d2bd069 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala @@ -0,0 +1,160 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind +import works.iterative.ui.components.laminar.tailwind.color.ColorWeight + +trait AlertComponentsModule: + self: IconsModule => + object alerts: + // TODO: use context functions and builder pattern to build the alerts + def alert( + message: HtmlMod, + icon: SvgElement, + color: ColorKind, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + div( + cls := "rounded-md p-4", + color(50).bg, + div( + cls := "flex", + icon, + div( + cls := "ml-3", + title.map(t => + h3( + cls := "text-sm font-medium", + color(800).text, + t + ) + ), + div( + cls := "text-sm", + title.map(_ => cls("mt-2")), + color(700).text, + message + ), + actions.map { act => + div( + cls := "mt-4", + act(color) + ) + } + ), + mods(color) + ) + ) + + def warning( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-warning`(), + ColorKind.yellow, + title = title, + actions = actions, + mods = mods + ) + + def error( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-error`(), + ColorKind.red, + title = title, + actions = actions, + mods = mods + ) + + def success( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-success`(), + ColorKind.green, + title = title, + actions = actions, + mods = mods + ) + + def info( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-info`(), + ColorKind.blue, + title = title, + actions = actions, + mods = mods + ) + + def buttons(buttons: (ColorKind => HtmlMod)*): ColorKind => HtmlMod = + color => + div( + cls("-mx-2 -my-1.5 flex"), + buttons.map(_(color)) + ) + + def button(title: String)(mods: HtmlMod*): ColorKind => HtmlMod = color => + L.button( + tpe("button"), + cls( + "first:ml-0 ml-3 rounded-md px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(800).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + title, + mods + ) + + def closeMod(mods: HtmlMod): ColorKind => HtmlMod = color => + val closeIcon: SvgElement = + import svg.* + svg( + cls := "h-5 w-5", + viewBox := "0 0 20 20", + fill := "currentColor", + path( + d := "M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" + ) + ) + div( + cls("ml-auto pl-3"), + L.button( + cls( + "-mx-1.5 -my-1.5 inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(500).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + mods, + aria.hidden := true, + closeIcon + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala new file mode 100644 index 0000000..fa374fe --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala @@ -0,0 +1,52 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait AppShellComponentsModule: + object shell: + object stackedLight extends StackedAppShell: + override def navCls = cls("border-b border-gray-200 bg-white") + override def headerCls = emptyMod + override def pageWrapper(content: HtmlMod*): HtmlMod = + div(cls("py-10"), content) + + object stackedBranded extends StackedAppShell: + override def navCls = cls("bg-indigo-600") + override def headerCls = cls("bg-white shadown-sm") + override def pageWrapper(content: HtmlMod*): HtmlMod = content + +trait StackedAppShell: + protected def navCls: HtmlMod + protected def headerCls: HtmlMod + protected def pageWrapper(content: HtmlMod*): HtmlMod + + def apply(pageTitle: HtmlMod)(navbarItems: HtmlMod*)( + content: HtmlMod* + ): HtmlElement = + div( + cls("min-h-full"), + navTag( + navCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + div(cls("flex h-16 items-center justify-between"), navbarItems) + ) + ), + pageWrapper( + headerTag( + headerCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + h1( + cls( + "text-3xl font-bold leading-tight tracking-tight text-gray-900" + ), + pageTitle + ) + ) + ), + mainTag( + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala new file mode 100644 index 0000000..f6d7d7f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait BadgeComponentsModule: + + object badges: + def pill(name: String, color: ColorKind): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + name + ) + + def pill(name: String, color: Signal[ColorKind]): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + cls <-- color.map(_.apply(800).text.toCSS), + cls <-- color.map(_.apply(100).bg.toCSS), + name + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala new file mode 100644 index 0000000..03a2c9c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait BreadcrumbsModule: + self: IconsModule => + + object breadcrumbs: + def container(mods: HtmlMod*): HtmlElement = + navTag(cls("flex"), aria.label("Breadcrumb"), mods) + + def list(mods: HtmlMod*): HtmlElement = + ol(cls("flex items-center space-x-4"), role("list"), mods) + + def homeItem(mods: HtmlMod*): HtmlElement = + li( + div( + a( + href("#"), + cls("text-gray-400 hover:text-gray-500"), + icons.home(svg.cls("h-5 w-5 flex-shrink-0")), + span(cls("sr-only"), "Home"), + mods + ) + ) + ) + + def item(mods: HtmlMod*): HtmlElement = + li( + div( + cls("flex items-center"), + icons.`chevron-right-solid`( + svg.cls("h-5 w-5 flex-shrink-0 text-gray-400") + ), + a( + href("#"), + cls("ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala new file mode 100644 index 0000000..27f3cf2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala @@ -0,0 +1,102 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +trait ButtonComponentsModule: + + object buttons: + + private inline def srHelp(text: String): Modifier[HtmlElement] = + span(cls := "sr-only", text) + + val sharedButtonClasses = + "inline-flex justify-center py-2 px-4 border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + + val sharedButtonMod: HtmlMod = cls(sharedButtonClasses) + + val primaryButtonClasses = + "border-transparent text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300" + val primaryButtonMod: HtmlMod = cls(primaryButtonClasses) + + val secondaryButtonClasses = + "border-gray-300 text-gray-700 hover:bg-gray-50" + val secondaryButtonMod: HtmlMod = cls(secondaryButtonClasses) + + val neutralButtonClasses = + "border-gray-300 text-gray-700 bg-white hover:bg-gray-50" + val neutralButtonMod: HtmlMod = cls(neutralButtonClasses) + + val positiveButtonClasses = + "border-transparent text-white bg-green-600 hover:bg-green-700" + val positiveButtonMod: HtmlMod = cls(positiveButtonClasses) + + val negativeButtonClasses = + "border-transparent text-white bg-red-600 hover:bg-red-700" + val negativeButtonMod: HtmlMod = cls(negativeButtonClasses) + + def button( + text: Modifier[HtmlElement], + id: Option[String], + icon: Option[SvgElement] = None, + buttonType: String = "submit", + primary: Boolean = false + )(mods: HtmlMod*): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe(buttonType), + sharedButtonMod, + if primary then primaryButtonMod else secondaryButtonMod, + icon, + text, + mods + ) + + def primaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "submit" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, true)(mods*) + + def secondaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "button" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, false)(mods*) + + def inlineButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srText.map(srHelp(_)), + icon + ) + + def iconButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srText.map(srHelp(_)), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala new file mode 100644 index 0000000..ae4ab86 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala @@ -0,0 +1,56 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +import io.laminext.syntax.core.* + +trait ComboboxModule: + self: IconsModule => + + object combobox: + object simple: + def container(mods: HtmlMod*): Div = div(cls("relative mt-2"), mods) + + def button(mods: HtmlMod*): Button = + L.button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + mods + ) + + def options(mods: HtmlMod*) = + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + role := "listbox", + mods + ) + + def option(isActive: Signal[Boolean], isSelected: Signal[Boolean])( + mods: HtmlMod* + ) = + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- isActive, + cls.toggle("text-gray-900") <-- isActive.not, + cls.toggle("font-semibold") <-- isSelected, + mods + ) + + def optionValue(l: String): HtmlElement = + span(cls("block truncate"), l) + + def checkmark(isActive: Signal[Boolean]): HtmlElement = + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- isActive.not, + cls.toggle("text-white") <-- isActive, + icons.check(svg.cls("h-5 w-5")) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala new file mode 100644 index 0000000..f5a8873 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ContainerComponentsModule: + object container: + /** Full-width on mobile, constrained with padded content above */ + def default(content: HtmlMod*): HtmlElement = + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + + def padded(content: HtmlMod*): HtmlElement = + default(cls("px-4"), content) + + def narrow(content: HtmlMod*): HtmlElement = + padded( + div(cls("mx-auto max-w-3xl"), content) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala new file mode 100644 index 0000000..d601ab6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala @@ -0,0 +1,117 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.core.FileRef +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.laminar.* +import works.iterative.core.UserMessage + +trait DetailComponentsModule: + self: IconsModule => + object details: + def sectionHeader( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls( + "flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls := "px-4 sm:px-0", + h3( + cls := "text-base font-semibold leading-7 text-gray-900", + title + ), + subtitle.map(st => + p( + cls := "mt-1 max-w-2xl text-sm leading-6 text-gray-500", + st + ) + ) + ), + div(cls("flex-shrink-0"), actions) + ) + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + )(content: HtmlMod): HtmlElement = + div(sectionHeader(title, subtitle, actions), content) + + def fields(items: Node*): HtmlElement = + div( + dl( + cls := "divide-y divide-gray-100", + items + ) + ) + + def field( + title: Node, + content: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "px-2 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0", + dt( + cls := "text-sm font-medium leading-6 text-gray-900", + title + ), + dd( + cls := "mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0", + content + ) + ) + + def files( + fs: Seq[FileRef], + fileMods: Option[(FileRef, Int) => HtmlMod] = None + )( + mods: HtmlMod* + )(using ComponentContext[?]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-100 rounded-md border border-gray-200", + fs.zipWithIndex + .map((f, i) => + fileMods match + case Some(fm) => file(f)(fm(f, i)) + case _ => file(f)() + ), + mods + ) + + def file(f: FileRef)(mods: HtmlMod*)(using + ComponentContext[?] + ): HtmlElement = + li( + cls := "flex items-center justify-between py-4 pl-4 pr-5 text-sm leading-6", + div( + cls := "flex w-0 flex-1 items-center", + icons.`paper-clip-solid`(), + div( + cls := "ml-4 flex min-w-0 flex-1 gap-2", + span( + cls := "truncate font-medium", + f.name + ), + f.sizeString.map(size => + span(cls := "flex-shrink-0 text-gray-400", size) + ) + ) + ), + div( + cls := "ml-4 flex-shrink-0", + a( + href := f.url, + target := "_blank", + cls := "font-medium text-indigo-600 hover:text-indigo-500", + UserMessage("file.download").asString + ) + ), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala new file mode 100644 index 0000000..e9d7f1b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala @@ -0,0 +1,202 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html +import io.laminext.syntax.core.* + +trait FormComponentsModule: + self: IconsModule => + + object forms: + + val inputClasses = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls("space-y-6 sm:space-y-5"), + div( + h3(cls("text-lg leading-6 font-medium text-gray-900"), title), + subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) + ), + div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) + ) + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: Modifier[HtmlElement]* + ): ReactiveHtmlElement[html.Label] = + L.label( + cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), + forId.map(id => L.forId(id)), + labelText, + if required then sup(cls("text-gray-400"), "* povinné pole") + else emptyMod, + mods + ) + + def field( + label: Modifier[HtmlElement] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls( + "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5" + ), + label, + div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) + ) + + def field( + id: String, + labelText: String, + input: HtmlElement, + help: Option[String] + ): HtmlElement = + field( + label(labelText, Some(id))() + )( + input.amend(idAttr(id)), + help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) + ) + + def form(mods: Modifier[HtmlElement]*)( + sections: Modifier[HtmlElement]* + )(actions: Modifier[HtmlElement]*): ReactiveHtmlElement[html.Form] = + L.form( + cls("space-y-8 divide-y divide-gray-200"), + mods, + sections, + div( + cls("pt-5"), + div(cls("flex justify-end"), actions) + ) + ) + + def inlineForm( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + def errorTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-red-600") + + def validationError(text: Modifier[HtmlElement]): HtmlElement = + p(errorTextMods, text) + + def helpTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-gray-500") + + def fieldHelp(text: Modifier[HtmlElement]): HtmlElement = + p(helpTextMods, text) + + def inputField( + id: String, + labelText: String, + placeholderText: Option[String] = None, + inputType: String = "text", + helpText: Option[String] = None + ): HtmlElement = + field( + id, + labelText, + input(id, inputType, placeholderText)(), + helpText + ) + + def input( + id: String, + inputType: String = "text", + placeholderText: Option[String] = None + )(mods: HtmlMod*): HtmlElement = + L.input( + cls(inputClasses), + idAttr(id), + nameAttr(id), + placeholderText.map(placeholder(_)), + tpe(inputType), + mods + ) + + def comboBoxSimple( + options: List[(String, String)], + selectedInitially: Option[String] = None, + id: Option[String] = None, + name: Option[String] = None + ): HtmlElement = + val expanded = Var(false) + val selected = Var(selectedInitially) + div( + cls("relative mt-2"), + L.input( + cls( + "w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + id.map(idAttr(_)), + name.map(nameAttr(_)), + tpe("text"), + role("combobox"), + aria.controls("options"), + aria.expanded <-- expanded + ), + button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + onClick.preventDefault --> (_ => expanded.toggle()) + ), + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + cls.toggle("hidden") <-- expanded.signal.not, + id.map(i => idAttr(s"${i}-options")), + role := "listbox", + // Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation. + // Active: "text-white bg-indigo-600", Not Active: "text-gray-900" + for (((v, l), i) <- options.zipWithIndex) + yield + val active = Var(false) + val isSelected = selected.signal.map(_.contains(v)) + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- active.signal, + cls.toggle("text-gray-900") <-- active.signal.not, + cls.toggle("font-semibold") <-- isSelected, + id.map(cid => idAttr(s"${cid}-option-${i}")), + role := "option", + tabIndex := -1, + // Selected: "font-semibold" + span(cls("block truncate"), l), + // Checkmark, only display for selected option. + // Active: "text-white", Not Active: "text-indigo-600" + isSelected.childWhenTrue( + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- active.signal.not, + cls.toggle("text-white") <-- active.signal, + icons.check(svg.cls("h-5 w-5")) + ) + ), + onClick.preventDefault.mapTo( + v + ) --> selected.writer.contramapSome, + onMouseEnter --> (_ => active.set(true)), + onMouseLeave --> (_ => active.set(false)) + ) + // More items... + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala new file mode 100644 index 0000000..6a44cce --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait HeadingsComponentsModule: + object headings: + def section(title: Node, actions: HtmlElement*): HtmlElement = + div( + cls("border-b border-gray-200 pb-5"), + div( + cls("sm:flex sm:items-center sm:justify-between"), + h3(cls("text-base font-semibold leading-6 text-gray-900"), title), + div( + cls("mt-3 flex sm:ml-4 sm:mt-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) + + def sectionWithSubtitle( + title: Node, + subtitle: Node, + actions: HtmlElement* + ): HtmlElement = + div( + cls("border-b border-gray-200 bg-white px-4 py-5 sm:px-6"), + div( + cls( + "-ml-4 -mt-4 flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls("ml-4 mt-4"), + h3( + cls("text-base font-semibold leading-6 text-gray-900"), + title + ), + p( + cls("mt-1 text-sm text-gray-500"), + subtitle + ) + ), + div( + cls("ml-4 mt-4 flex-shrink-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala new file mode 100644 index 0000000..be16e48 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala @@ -0,0 +1,321 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.codecs +import works.iterative.ui.components.laminar.CustomAttrs + +trait IconsModule: + object icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + def upload(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 24 24"), + path(d := "M0 0h24v24H0z", fill("none")), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + + def home(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 20 20"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + clipRule := "evenodd", + fillRule := "evenodd", + d := "M9.293 2.293a1 1 0 011.414 0l7 7A1 1 0 0117 11h-1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-3a1 1 0 00-1-1H9a1 1 0 00-1 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-6H3a1 1 0 01-.707-1.707l7-7z" + ) + ) + + def `x-mark-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + def `arrow-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18") + ) + ) + + def `chevron-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M15.75 19.5L8.25 12l7.5-7.5") + ) + ) + + def `chevron-right-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + clipRule("evenodd"), + fillRule("evenodd"), + d( + "M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" + ) + ) + ) + + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) + + def `paper-clip-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + cls := "flex-shrink-0 text-gray-400", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M15.621 4.379a3 3 0 00-4.242 0l-7 7a3 3 0 004.241 4.243h.001l.497-.5a.75.75 0 011.064 1.057l-.498.501-.002.002a4.5 4.5 0 01-6.364-6.364l7-7a4.5 4.5 0 016.368 6.36l-3.455 3.553A2.625 2.625 0 119.52 9.52l3.45-3.451a.75.75 0 111.061 1.06l-3.45 3.451a1.125 1.125 0 001.587 1.595l3.454-3.553a3 3 0 000-4.242z", + clipRule := "evenodd" + ) + ) + + def `exclamation-circle-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-warning`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-yellow-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-error`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-red-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z", + clipRule := "evenodd" + ) + ) + + def `alert-success`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-green-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z", + clipRule := "evenodd" + ) + ) + + def `alert-info`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-blue-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z", + clipRule := "evenodd" + ) + ) + + def spinner(mods: SvgMod*): SvgElement = svg( + withDefault(mods, cls("h-4 w-4")), + svgAttr("role", codecs.StringAsIsCodec, None) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + def arrowPath(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + fill("none"), + stroke("currentColor"), + strokeWidth("1.5"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d( + "M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" + ) + ) + ) + + def chevronUpDown(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z", + clipRule := "evenodd" + ) + ) + + def check(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z", + clipRule := "evenodd" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala new file mode 100644 index 0000000..70c4a7c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* + +trait LayoutModule: + object layout: + def cardMod: HtmlMod = cls("bg-white shadow px-4 py-5 sm:rounded-lg sm:p-6") + def card(content: Modifier[HtmlElement]*): HtmlElement = + div(cardMod, content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala new file mode 100644 index 0000000..5b64ad7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala @@ -0,0 +1,88 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait ListComponentsModule: + self: BadgeComponentsModule => + + object list: + def label( + text: String, + color: ColorKind + ): HtmlElement = badges.pill(text, color) + + def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None, + contentMod: Modifier[HtmlElement] = emptyMod + ): LI = + li( + cls("group"), + div( + contentMod, + cls( + "relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" + ), + avatar.map(a => + div( + cls("flex-shrink-0"), + div( + cls( + "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" + ), + a + ) + ) + ), + div( + cls("flex-1 min-w-0"), + p( + cls("text-sm font-medium text-gray-900"), + title, + span(cls("float-right"), right) + ), + subtitle.map(st => + p( + cls("text-sm text-gray-500 truncate"), + st + ) + ) + ) + ) + ) + + def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = + ul( + cls("relative z-0 divide-y divide-gray-200"), + role("list"), + children + ) + + def listSection( + header: String, + list: HtmlElement + ): Div = + div( + cls("relative"), + div( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + list + ) + + def navigation(sections: Modifier[HtmlElement]): HtmlElement = + navTag( + cls("flex-1 min-h-0 overflow-y-auto"), + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala new file mode 100644 index 0000000..d2bd069 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala @@ -0,0 +1,160 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind +import works.iterative.ui.components.laminar.tailwind.color.ColorWeight + +trait AlertComponentsModule: + self: IconsModule => + object alerts: + // TODO: use context functions and builder pattern to build the alerts + def alert( + message: HtmlMod, + icon: SvgElement, + color: ColorKind, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + div( + cls := "rounded-md p-4", + color(50).bg, + div( + cls := "flex", + icon, + div( + cls := "ml-3", + title.map(t => + h3( + cls := "text-sm font-medium", + color(800).text, + t + ) + ), + div( + cls := "text-sm", + title.map(_ => cls("mt-2")), + color(700).text, + message + ), + actions.map { act => + div( + cls := "mt-4", + act(color) + ) + } + ), + mods(color) + ) + ) + + def warning( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-warning`(), + ColorKind.yellow, + title = title, + actions = actions, + mods = mods + ) + + def error( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-error`(), + ColorKind.red, + title = title, + actions = actions, + mods = mods + ) + + def success( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-success`(), + ColorKind.green, + title = title, + actions = actions, + mods = mods + ) + + def info( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-info`(), + ColorKind.blue, + title = title, + actions = actions, + mods = mods + ) + + def buttons(buttons: (ColorKind => HtmlMod)*): ColorKind => HtmlMod = + color => + div( + cls("-mx-2 -my-1.5 flex"), + buttons.map(_(color)) + ) + + def button(title: String)(mods: HtmlMod*): ColorKind => HtmlMod = color => + L.button( + tpe("button"), + cls( + "first:ml-0 ml-3 rounded-md px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(800).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + title, + mods + ) + + def closeMod(mods: HtmlMod): ColorKind => HtmlMod = color => + val closeIcon: SvgElement = + import svg.* + svg( + cls := "h-5 w-5", + viewBox := "0 0 20 20", + fill := "currentColor", + path( + d := "M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" + ) + ) + div( + cls("ml-auto pl-3"), + L.button( + cls( + "-mx-1.5 -my-1.5 inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(500).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + mods, + aria.hidden := true, + closeIcon + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala new file mode 100644 index 0000000..fa374fe --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala @@ -0,0 +1,52 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait AppShellComponentsModule: + object shell: + object stackedLight extends StackedAppShell: + override def navCls = cls("border-b border-gray-200 bg-white") + override def headerCls = emptyMod + override def pageWrapper(content: HtmlMod*): HtmlMod = + div(cls("py-10"), content) + + object stackedBranded extends StackedAppShell: + override def navCls = cls("bg-indigo-600") + override def headerCls = cls("bg-white shadown-sm") + override def pageWrapper(content: HtmlMod*): HtmlMod = content + +trait StackedAppShell: + protected def navCls: HtmlMod + protected def headerCls: HtmlMod + protected def pageWrapper(content: HtmlMod*): HtmlMod + + def apply(pageTitle: HtmlMod)(navbarItems: HtmlMod*)( + content: HtmlMod* + ): HtmlElement = + div( + cls("min-h-full"), + navTag( + navCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + div(cls("flex h-16 items-center justify-between"), navbarItems) + ) + ), + pageWrapper( + headerTag( + headerCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + h1( + cls( + "text-3xl font-bold leading-tight tracking-tight text-gray-900" + ), + pageTitle + ) + ) + ), + mainTag( + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala new file mode 100644 index 0000000..f6d7d7f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait BadgeComponentsModule: + + object badges: + def pill(name: String, color: ColorKind): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + name + ) + + def pill(name: String, color: Signal[ColorKind]): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + cls <-- color.map(_.apply(800).text.toCSS), + cls <-- color.map(_.apply(100).bg.toCSS), + name + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala new file mode 100644 index 0000000..03a2c9c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait BreadcrumbsModule: + self: IconsModule => + + object breadcrumbs: + def container(mods: HtmlMod*): HtmlElement = + navTag(cls("flex"), aria.label("Breadcrumb"), mods) + + def list(mods: HtmlMod*): HtmlElement = + ol(cls("flex items-center space-x-4"), role("list"), mods) + + def homeItem(mods: HtmlMod*): HtmlElement = + li( + div( + a( + href("#"), + cls("text-gray-400 hover:text-gray-500"), + icons.home(svg.cls("h-5 w-5 flex-shrink-0")), + span(cls("sr-only"), "Home"), + mods + ) + ) + ) + + def item(mods: HtmlMod*): HtmlElement = + li( + div( + cls("flex items-center"), + icons.`chevron-right-solid`( + svg.cls("h-5 w-5 flex-shrink-0 text-gray-400") + ), + a( + href("#"), + cls("ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala new file mode 100644 index 0000000..27f3cf2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala @@ -0,0 +1,102 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +trait ButtonComponentsModule: + + object buttons: + + private inline def srHelp(text: String): Modifier[HtmlElement] = + span(cls := "sr-only", text) + + val sharedButtonClasses = + "inline-flex justify-center py-2 px-4 border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + + val sharedButtonMod: HtmlMod = cls(sharedButtonClasses) + + val primaryButtonClasses = + "border-transparent text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300" + val primaryButtonMod: HtmlMod = cls(primaryButtonClasses) + + val secondaryButtonClasses = + "border-gray-300 text-gray-700 hover:bg-gray-50" + val secondaryButtonMod: HtmlMod = cls(secondaryButtonClasses) + + val neutralButtonClasses = + "border-gray-300 text-gray-700 bg-white hover:bg-gray-50" + val neutralButtonMod: HtmlMod = cls(neutralButtonClasses) + + val positiveButtonClasses = + "border-transparent text-white bg-green-600 hover:bg-green-700" + val positiveButtonMod: HtmlMod = cls(positiveButtonClasses) + + val negativeButtonClasses = + "border-transparent text-white bg-red-600 hover:bg-red-700" + val negativeButtonMod: HtmlMod = cls(negativeButtonClasses) + + def button( + text: Modifier[HtmlElement], + id: Option[String], + icon: Option[SvgElement] = None, + buttonType: String = "submit", + primary: Boolean = false + )(mods: HtmlMod*): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe(buttonType), + sharedButtonMod, + if primary then primaryButtonMod else secondaryButtonMod, + icon, + text, + mods + ) + + def primaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "submit" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, true)(mods*) + + def secondaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "button" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, false)(mods*) + + def inlineButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srText.map(srHelp(_)), + icon + ) + + def iconButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srText.map(srHelp(_)), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala new file mode 100644 index 0000000..ae4ab86 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala @@ -0,0 +1,56 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +import io.laminext.syntax.core.* + +trait ComboboxModule: + self: IconsModule => + + object combobox: + object simple: + def container(mods: HtmlMod*): Div = div(cls("relative mt-2"), mods) + + def button(mods: HtmlMod*): Button = + L.button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + mods + ) + + def options(mods: HtmlMod*) = + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + role := "listbox", + mods + ) + + def option(isActive: Signal[Boolean], isSelected: Signal[Boolean])( + mods: HtmlMod* + ) = + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- isActive, + cls.toggle("text-gray-900") <-- isActive.not, + cls.toggle("font-semibold") <-- isSelected, + mods + ) + + def optionValue(l: String): HtmlElement = + span(cls("block truncate"), l) + + def checkmark(isActive: Signal[Boolean]): HtmlElement = + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- isActive.not, + cls.toggle("text-white") <-- isActive, + icons.check(svg.cls("h-5 w-5")) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala new file mode 100644 index 0000000..f5a8873 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ContainerComponentsModule: + object container: + /** Full-width on mobile, constrained with padded content above */ + def default(content: HtmlMod*): HtmlElement = + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + + def padded(content: HtmlMod*): HtmlElement = + default(cls("px-4"), content) + + def narrow(content: HtmlMod*): HtmlElement = + padded( + div(cls("mx-auto max-w-3xl"), content) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala new file mode 100644 index 0000000..d601ab6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala @@ -0,0 +1,117 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.core.FileRef +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.laminar.* +import works.iterative.core.UserMessage + +trait DetailComponentsModule: + self: IconsModule => + object details: + def sectionHeader( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls( + "flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls := "px-4 sm:px-0", + h3( + cls := "text-base font-semibold leading-7 text-gray-900", + title + ), + subtitle.map(st => + p( + cls := "mt-1 max-w-2xl text-sm leading-6 text-gray-500", + st + ) + ) + ), + div(cls("flex-shrink-0"), actions) + ) + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + )(content: HtmlMod): HtmlElement = + div(sectionHeader(title, subtitle, actions), content) + + def fields(items: Node*): HtmlElement = + div( + dl( + cls := "divide-y divide-gray-100", + items + ) + ) + + def field( + title: Node, + content: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "px-2 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0", + dt( + cls := "text-sm font-medium leading-6 text-gray-900", + title + ), + dd( + cls := "mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0", + content + ) + ) + + def files( + fs: Seq[FileRef], + fileMods: Option[(FileRef, Int) => HtmlMod] = None + )( + mods: HtmlMod* + )(using ComponentContext[?]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-100 rounded-md border border-gray-200", + fs.zipWithIndex + .map((f, i) => + fileMods match + case Some(fm) => file(f)(fm(f, i)) + case _ => file(f)() + ), + mods + ) + + def file(f: FileRef)(mods: HtmlMod*)(using + ComponentContext[?] + ): HtmlElement = + li( + cls := "flex items-center justify-between py-4 pl-4 pr-5 text-sm leading-6", + div( + cls := "flex w-0 flex-1 items-center", + icons.`paper-clip-solid`(), + div( + cls := "ml-4 flex min-w-0 flex-1 gap-2", + span( + cls := "truncate font-medium", + f.name + ), + f.sizeString.map(size => + span(cls := "flex-shrink-0 text-gray-400", size) + ) + ) + ), + div( + cls := "ml-4 flex-shrink-0", + a( + href := f.url, + target := "_blank", + cls := "font-medium text-indigo-600 hover:text-indigo-500", + UserMessage("file.download").asString + ) + ), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala new file mode 100644 index 0000000..e9d7f1b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala @@ -0,0 +1,202 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html +import io.laminext.syntax.core.* + +trait FormComponentsModule: + self: IconsModule => + + object forms: + + val inputClasses = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls("space-y-6 sm:space-y-5"), + div( + h3(cls("text-lg leading-6 font-medium text-gray-900"), title), + subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) + ), + div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) + ) + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: Modifier[HtmlElement]* + ): ReactiveHtmlElement[html.Label] = + L.label( + cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), + forId.map(id => L.forId(id)), + labelText, + if required then sup(cls("text-gray-400"), "* povinné pole") + else emptyMod, + mods + ) + + def field( + label: Modifier[HtmlElement] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls( + "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5" + ), + label, + div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) + ) + + def field( + id: String, + labelText: String, + input: HtmlElement, + help: Option[String] + ): HtmlElement = + field( + label(labelText, Some(id))() + )( + input.amend(idAttr(id)), + help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) + ) + + def form(mods: Modifier[HtmlElement]*)( + sections: Modifier[HtmlElement]* + )(actions: Modifier[HtmlElement]*): ReactiveHtmlElement[html.Form] = + L.form( + cls("space-y-8 divide-y divide-gray-200"), + mods, + sections, + div( + cls("pt-5"), + div(cls("flex justify-end"), actions) + ) + ) + + def inlineForm( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + def errorTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-red-600") + + def validationError(text: Modifier[HtmlElement]): HtmlElement = + p(errorTextMods, text) + + def helpTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-gray-500") + + def fieldHelp(text: Modifier[HtmlElement]): HtmlElement = + p(helpTextMods, text) + + def inputField( + id: String, + labelText: String, + placeholderText: Option[String] = None, + inputType: String = "text", + helpText: Option[String] = None + ): HtmlElement = + field( + id, + labelText, + input(id, inputType, placeholderText)(), + helpText + ) + + def input( + id: String, + inputType: String = "text", + placeholderText: Option[String] = None + )(mods: HtmlMod*): HtmlElement = + L.input( + cls(inputClasses), + idAttr(id), + nameAttr(id), + placeholderText.map(placeholder(_)), + tpe(inputType), + mods + ) + + def comboBoxSimple( + options: List[(String, String)], + selectedInitially: Option[String] = None, + id: Option[String] = None, + name: Option[String] = None + ): HtmlElement = + val expanded = Var(false) + val selected = Var(selectedInitially) + div( + cls("relative mt-2"), + L.input( + cls( + "w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + id.map(idAttr(_)), + name.map(nameAttr(_)), + tpe("text"), + role("combobox"), + aria.controls("options"), + aria.expanded <-- expanded + ), + button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + onClick.preventDefault --> (_ => expanded.toggle()) + ), + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + cls.toggle("hidden") <-- expanded.signal.not, + id.map(i => idAttr(s"${i}-options")), + role := "listbox", + // Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation. + // Active: "text-white bg-indigo-600", Not Active: "text-gray-900" + for (((v, l), i) <- options.zipWithIndex) + yield + val active = Var(false) + val isSelected = selected.signal.map(_.contains(v)) + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- active.signal, + cls.toggle("text-gray-900") <-- active.signal.not, + cls.toggle("font-semibold") <-- isSelected, + id.map(cid => idAttr(s"${cid}-option-${i}")), + role := "option", + tabIndex := -1, + // Selected: "font-semibold" + span(cls("block truncate"), l), + // Checkmark, only display for selected option. + // Active: "text-white", Not Active: "text-indigo-600" + isSelected.childWhenTrue( + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- active.signal.not, + cls.toggle("text-white") <-- active.signal, + icons.check(svg.cls("h-5 w-5")) + ) + ), + onClick.preventDefault.mapTo( + v + ) --> selected.writer.contramapSome, + onMouseEnter --> (_ => active.set(true)), + onMouseLeave --> (_ => active.set(false)) + ) + // More items... + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala new file mode 100644 index 0000000..6a44cce --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait HeadingsComponentsModule: + object headings: + def section(title: Node, actions: HtmlElement*): HtmlElement = + div( + cls("border-b border-gray-200 pb-5"), + div( + cls("sm:flex sm:items-center sm:justify-between"), + h3(cls("text-base font-semibold leading-6 text-gray-900"), title), + div( + cls("mt-3 flex sm:ml-4 sm:mt-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) + + def sectionWithSubtitle( + title: Node, + subtitle: Node, + actions: HtmlElement* + ): HtmlElement = + div( + cls("border-b border-gray-200 bg-white px-4 py-5 sm:px-6"), + div( + cls( + "-ml-4 -mt-4 flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls("ml-4 mt-4"), + h3( + cls("text-base font-semibold leading-6 text-gray-900"), + title + ), + p( + cls("mt-1 text-sm text-gray-500"), + subtitle + ) + ), + div( + cls("ml-4 mt-4 flex-shrink-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala new file mode 100644 index 0000000..be16e48 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala @@ -0,0 +1,321 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.codecs +import works.iterative.ui.components.laminar.CustomAttrs + +trait IconsModule: + object icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + def upload(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 24 24"), + path(d := "M0 0h24v24H0z", fill("none")), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + + def home(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 20 20"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + clipRule := "evenodd", + fillRule := "evenodd", + d := "M9.293 2.293a1 1 0 011.414 0l7 7A1 1 0 0117 11h-1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-3a1 1 0 00-1-1H9a1 1 0 00-1 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-6H3a1 1 0 01-.707-1.707l7-7z" + ) + ) + + def `x-mark-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + def `arrow-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18") + ) + ) + + def `chevron-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M15.75 19.5L8.25 12l7.5-7.5") + ) + ) + + def `chevron-right-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + clipRule("evenodd"), + fillRule("evenodd"), + d( + "M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" + ) + ) + ) + + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) + + def `paper-clip-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + cls := "flex-shrink-0 text-gray-400", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M15.621 4.379a3 3 0 00-4.242 0l-7 7a3 3 0 004.241 4.243h.001l.497-.5a.75.75 0 011.064 1.057l-.498.501-.002.002a4.5 4.5 0 01-6.364-6.364l7-7a4.5 4.5 0 016.368 6.36l-3.455 3.553A2.625 2.625 0 119.52 9.52l3.45-3.451a.75.75 0 111.061 1.06l-3.45 3.451a1.125 1.125 0 001.587 1.595l3.454-3.553a3 3 0 000-4.242z", + clipRule := "evenodd" + ) + ) + + def `exclamation-circle-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-warning`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-yellow-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-error`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-red-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z", + clipRule := "evenodd" + ) + ) + + def `alert-success`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-green-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z", + clipRule := "evenodd" + ) + ) + + def `alert-info`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-blue-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z", + clipRule := "evenodd" + ) + ) + + def spinner(mods: SvgMod*): SvgElement = svg( + withDefault(mods, cls("h-4 w-4")), + svgAttr("role", codecs.StringAsIsCodec, None) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + def arrowPath(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + fill("none"), + stroke("currentColor"), + strokeWidth("1.5"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d( + "M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" + ) + ) + ) + + def chevronUpDown(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z", + clipRule := "evenodd" + ) + ) + + def check(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z", + clipRule := "evenodd" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala new file mode 100644 index 0000000..70c4a7c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* + +trait LayoutModule: + object layout: + def cardMod: HtmlMod = cls("bg-white shadow px-4 py-5 sm:rounded-lg sm:p-6") + def card(content: Modifier[HtmlElement]*): HtmlElement = + div(cardMod, content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala new file mode 100644 index 0000000..5b64ad7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala @@ -0,0 +1,88 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait ListComponentsModule: + self: BadgeComponentsModule => + + object list: + def label( + text: String, + color: ColorKind + ): HtmlElement = badges.pill(text, color) + + def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None, + contentMod: Modifier[HtmlElement] = emptyMod + ): LI = + li( + cls("group"), + div( + contentMod, + cls( + "relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" + ), + avatar.map(a => + div( + cls("flex-shrink-0"), + div( + cls( + "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" + ), + a + ) + ) + ), + div( + cls("flex-1 min-w-0"), + p( + cls("text-sm font-medium text-gray-900"), + title, + span(cls("float-right"), right) + ), + subtitle.map(st => + p( + cls("text-sm text-gray-500 truncate"), + st + ) + ) + ) + ) + ) + + def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = + ul( + cls("relative z-0 divide-y divide-gray-200"), + role("list"), + children + ) + + def listSection( + header: String, + list: HtmlElement + ): Div = + div( + cls("relative"), + div( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + list + ) + + def navigation(sections: Modifier[HtmlElement]): HtmlElement = + navTag( + cls("flex-1 min-h-0 overflow-y-auto"), + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala new file mode 100644 index 0000000..d4a2c56 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ListContainerComponentsModule: + object listContainer: + def simpleWithDividers(items: HtmlMod*) = + ul( + role("list"), + cls("divide-y divide-gray-200"), + items.map(li(cls("py-4"), _)) + ) + + def cardWithDividers(items: HtmlMod*) = + div( + cls("overflow-hidden rounded-md bg-white shadow"), + ul( + role := "list", + cls("divide-y divide-gray-200"), + items.map( + li( + cls("px-6 py-4"), + _ + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala new file mode 100644 index 0000000..d2bd069 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala @@ -0,0 +1,160 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind +import works.iterative.ui.components.laminar.tailwind.color.ColorWeight + +trait AlertComponentsModule: + self: IconsModule => + object alerts: + // TODO: use context functions and builder pattern to build the alerts + def alert( + message: HtmlMod, + icon: SvgElement, + color: ColorKind, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + div( + cls := "rounded-md p-4", + color(50).bg, + div( + cls := "flex", + icon, + div( + cls := "ml-3", + title.map(t => + h3( + cls := "text-sm font-medium", + color(800).text, + t + ) + ), + div( + cls := "text-sm", + title.map(_ => cls("mt-2")), + color(700).text, + message + ), + actions.map { act => + div( + cls := "mt-4", + act(color) + ) + } + ), + mods(color) + ) + ) + + def warning( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-warning`(), + ColorKind.yellow, + title = title, + actions = actions, + mods = mods + ) + + def error( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-error`(), + ColorKind.red, + title = title, + actions = actions, + mods = mods + ) + + def success( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-success`(), + ColorKind.green, + title = title, + actions = actions, + mods = mods + ) + + def info( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-info`(), + ColorKind.blue, + title = title, + actions = actions, + mods = mods + ) + + def buttons(buttons: (ColorKind => HtmlMod)*): ColorKind => HtmlMod = + color => + div( + cls("-mx-2 -my-1.5 flex"), + buttons.map(_(color)) + ) + + def button(title: String)(mods: HtmlMod*): ColorKind => HtmlMod = color => + L.button( + tpe("button"), + cls( + "first:ml-0 ml-3 rounded-md px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(800).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + title, + mods + ) + + def closeMod(mods: HtmlMod): ColorKind => HtmlMod = color => + val closeIcon: SvgElement = + import svg.* + svg( + cls := "h-5 w-5", + viewBox := "0 0 20 20", + fill := "currentColor", + path( + d := "M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" + ) + ) + div( + cls("ml-auto pl-3"), + L.button( + cls( + "-mx-1.5 -my-1.5 inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(500).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + mods, + aria.hidden := true, + closeIcon + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala new file mode 100644 index 0000000..fa374fe --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala @@ -0,0 +1,52 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait AppShellComponentsModule: + object shell: + object stackedLight extends StackedAppShell: + override def navCls = cls("border-b border-gray-200 bg-white") + override def headerCls = emptyMod + override def pageWrapper(content: HtmlMod*): HtmlMod = + div(cls("py-10"), content) + + object stackedBranded extends StackedAppShell: + override def navCls = cls("bg-indigo-600") + override def headerCls = cls("bg-white shadown-sm") + override def pageWrapper(content: HtmlMod*): HtmlMod = content + +trait StackedAppShell: + protected def navCls: HtmlMod + protected def headerCls: HtmlMod + protected def pageWrapper(content: HtmlMod*): HtmlMod + + def apply(pageTitle: HtmlMod)(navbarItems: HtmlMod*)( + content: HtmlMod* + ): HtmlElement = + div( + cls("min-h-full"), + navTag( + navCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + div(cls("flex h-16 items-center justify-between"), navbarItems) + ) + ), + pageWrapper( + headerTag( + headerCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + h1( + cls( + "text-3xl font-bold leading-tight tracking-tight text-gray-900" + ), + pageTitle + ) + ) + ), + mainTag( + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala new file mode 100644 index 0000000..f6d7d7f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait BadgeComponentsModule: + + object badges: + def pill(name: String, color: ColorKind): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + name + ) + + def pill(name: String, color: Signal[ColorKind]): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + cls <-- color.map(_.apply(800).text.toCSS), + cls <-- color.map(_.apply(100).bg.toCSS), + name + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala new file mode 100644 index 0000000..03a2c9c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait BreadcrumbsModule: + self: IconsModule => + + object breadcrumbs: + def container(mods: HtmlMod*): HtmlElement = + navTag(cls("flex"), aria.label("Breadcrumb"), mods) + + def list(mods: HtmlMod*): HtmlElement = + ol(cls("flex items-center space-x-4"), role("list"), mods) + + def homeItem(mods: HtmlMod*): HtmlElement = + li( + div( + a( + href("#"), + cls("text-gray-400 hover:text-gray-500"), + icons.home(svg.cls("h-5 w-5 flex-shrink-0")), + span(cls("sr-only"), "Home"), + mods + ) + ) + ) + + def item(mods: HtmlMod*): HtmlElement = + li( + div( + cls("flex items-center"), + icons.`chevron-right-solid`( + svg.cls("h-5 w-5 flex-shrink-0 text-gray-400") + ), + a( + href("#"), + cls("ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala new file mode 100644 index 0000000..27f3cf2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala @@ -0,0 +1,102 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +trait ButtonComponentsModule: + + object buttons: + + private inline def srHelp(text: String): Modifier[HtmlElement] = + span(cls := "sr-only", text) + + val sharedButtonClasses = + "inline-flex justify-center py-2 px-4 border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + + val sharedButtonMod: HtmlMod = cls(sharedButtonClasses) + + val primaryButtonClasses = + "border-transparent text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300" + val primaryButtonMod: HtmlMod = cls(primaryButtonClasses) + + val secondaryButtonClasses = + "border-gray-300 text-gray-700 hover:bg-gray-50" + val secondaryButtonMod: HtmlMod = cls(secondaryButtonClasses) + + val neutralButtonClasses = + "border-gray-300 text-gray-700 bg-white hover:bg-gray-50" + val neutralButtonMod: HtmlMod = cls(neutralButtonClasses) + + val positiveButtonClasses = + "border-transparent text-white bg-green-600 hover:bg-green-700" + val positiveButtonMod: HtmlMod = cls(positiveButtonClasses) + + val negativeButtonClasses = + "border-transparent text-white bg-red-600 hover:bg-red-700" + val negativeButtonMod: HtmlMod = cls(negativeButtonClasses) + + def button( + text: Modifier[HtmlElement], + id: Option[String], + icon: Option[SvgElement] = None, + buttonType: String = "submit", + primary: Boolean = false + )(mods: HtmlMod*): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe(buttonType), + sharedButtonMod, + if primary then primaryButtonMod else secondaryButtonMod, + icon, + text, + mods + ) + + def primaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "submit" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, true)(mods*) + + def secondaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "button" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, false)(mods*) + + def inlineButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srText.map(srHelp(_)), + icon + ) + + def iconButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srText.map(srHelp(_)), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala new file mode 100644 index 0000000..ae4ab86 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala @@ -0,0 +1,56 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +import io.laminext.syntax.core.* + +trait ComboboxModule: + self: IconsModule => + + object combobox: + object simple: + def container(mods: HtmlMod*): Div = div(cls("relative mt-2"), mods) + + def button(mods: HtmlMod*): Button = + L.button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + mods + ) + + def options(mods: HtmlMod*) = + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + role := "listbox", + mods + ) + + def option(isActive: Signal[Boolean], isSelected: Signal[Boolean])( + mods: HtmlMod* + ) = + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- isActive, + cls.toggle("text-gray-900") <-- isActive.not, + cls.toggle("font-semibold") <-- isSelected, + mods + ) + + def optionValue(l: String): HtmlElement = + span(cls("block truncate"), l) + + def checkmark(isActive: Signal[Boolean]): HtmlElement = + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- isActive.not, + cls.toggle("text-white") <-- isActive, + icons.check(svg.cls("h-5 w-5")) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala new file mode 100644 index 0000000..f5a8873 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ContainerComponentsModule: + object container: + /** Full-width on mobile, constrained with padded content above */ + def default(content: HtmlMod*): HtmlElement = + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + + def padded(content: HtmlMod*): HtmlElement = + default(cls("px-4"), content) + + def narrow(content: HtmlMod*): HtmlElement = + padded( + div(cls("mx-auto max-w-3xl"), content) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala new file mode 100644 index 0000000..d601ab6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala @@ -0,0 +1,117 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.core.FileRef +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.laminar.* +import works.iterative.core.UserMessage + +trait DetailComponentsModule: + self: IconsModule => + object details: + def sectionHeader( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls( + "flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls := "px-4 sm:px-0", + h3( + cls := "text-base font-semibold leading-7 text-gray-900", + title + ), + subtitle.map(st => + p( + cls := "mt-1 max-w-2xl text-sm leading-6 text-gray-500", + st + ) + ) + ), + div(cls("flex-shrink-0"), actions) + ) + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + )(content: HtmlMod): HtmlElement = + div(sectionHeader(title, subtitle, actions), content) + + def fields(items: Node*): HtmlElement = + div( + dl( + cls := "divide-y divide-gray-100", + items + ) + ) + + def field( + title: Node, + content: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "px-2 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0", + dt( + cls := "text-sm font-medium leading-6 text-gray-900", + title + ), + dd( + cls := "mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0", + content + ) + ) + + def files( + fs: Seq[FileRef], + fileMods: Option[(FileRef, Int) => HtmlMod] = None + )( + mods: HtmlMod* + )(using ComponentContext[?]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-100 rounded-md border border-gray-200", + fs.zipWithIndex + .map((f, i) => + fileMods match + case Some(fm) => file(f)(fm(f, i)) + case _ => file(f)() + ), + mods + ) + + def file(f: FileRef)(mods: HtmlMod*)(using + ComponentContext[?] + ): HtmlElement = + li( + cls := "flex items-center justify-between py-4 pl-4 pr-5 text-sm leading-6", + div( + cls := "flex w-0 flex-1 items-center", + icons.`paper-clip-solid`(), + div( + cls := "ml-4 flex min-w-0 flex-1 gap-2", + span( + cls := "truncate font-medium", + f.name + ), + f.sizeString.map(size => + span(cls := "flex-shrink-0 text-gray-400", size) + ) + ) + ), + div( + cls := "ml-4 flex-shrink-0", + a( + href := f.url, + target := "_blank", + cls := "font-medium text-indigo-600 hover:text-indigo-500", + UserMessage("file.download").asString + ) + ), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala new file mode 100644 index 0000000..e9d7f1b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala @@ -0,0 +1,202 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html +import io.laminext.syntax.core.* + +trait FormComponentsModule: + self: IconsModule => + + object forms: + + val inputClasses = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls("space-y-6 sm:space-y-5"), + div( + h3(cls("text-lg leading-6 font-medium text-gray-900"), title), + subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) + ), + div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) + ) + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: Modifier[HtmlElement]* + ): ReactiveHtmlElement[html.Label] = + L.label( + cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), + forId.map(id => L.forId(id)), + labelText, + if required then sup(cls("text-gray-400"), "* povinné pole") + else emptyMod, + mods + ) + + def field( + label: Modifier[HtmlElement] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls( + "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5" + ), + label, + div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) + ) + + def field( + id: String, + labelText: String, + input: HtmlElement, + help: Option[String] + ): HtmlElement = + field( + label(labelText, Some(id))() + )( + input.amend(idAttr(id)), + help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) + ) + + def form(mods: Modifier[HtmlElement]*)( + sections: Modifier[HtmlElement]* + )(actions: Modifier[HtmlElement]*): ReactiveHtmlElement[html.Form] = + L.form( + cls("space-y-8 divide-y divide-gray-200"), + mods, + sections, + div( + cls("pt-5"), + div(cls("flex justify-end"), actions) + ) + ) + + def inlineForm( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + def errorTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-red-600") + + def validationError(text: Modifier[HtmlElement]): HtmlElement = + p(errorTextMods, text) + + def helpTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-gray-500") + + def fieldHelp(text: Modifier[HtmlElement]): HtmlElement = + p(helpTextMods, text) + + def inputField( + id: String, + labelText: String, + placeholderText: Option[String] = None, + inputType: String = "text", + helpText: Option[String] = None + ): HtmlElement = + field( + id, + labelText, + input(id, inputType, placeholderText)(), + helpText + ) + + def input( + id: String, + inputType: String = "text", + placeholderText: Option[String] = None + )(mods: HtmlMod*): HtmlElement = + L.input( + cls(inputClasses), + idAttr(id), + nameAttr(id), + placeholderText.map(placeholder(_)), + tpe(inputType), + mods + ) + + def comboBoxSimple( + options: List[(String, String)], + selectedInitially: Option[String] = None, + id: Option[String] = None, + name: Option[String] = None + ): HtmlElement = + val expanded = Var(false) + val selected = Var(selectedInitially) + div( + cls("relative mt-2"), + L.input( + cls( + "w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + id.map(idAttr(_)), + name.map(nameAttr(_)), + tpe("text"), + role("combobox"), + aria.controls("options"), + aria.expanded <-- expanded + ), + button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + onClick.preventDefault --> (_ => expanded.toggle()) + ), + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + cls.toggle("hidden") <-- expanded.signal.not, + id.map(i => idAttr(s"${i}-options")), + role := "listbox", + // Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation. + // Active: "text-white bg-indigo-600", Not Active: "text-gray-900" + for (((v, l), i) <- options.zipWithIndex) + yield + val active = Var(false) + val isSelected = selected.signal.map(_.contains(v)) + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- active.signal, + cls.toggle("text-gray-900") <-- active.signal.not, + cls.toggle("font-semibold") <-- isSelected, + id.map(cid => idAttr(s"${cid}-option-${i}")), + role := "option", + tabIndex := -1, + // Selected: "font-semibold" + span(cls("block truncate"), l), + // Checkmark, only display for selected option. + // Active: "text-white", Not Active: "text-indigo-600" + isSelected.childWhenTrue( + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- active.signal.not, + cls.toggle("text-white") <-- active.signal, + icons.check(svg.cls("h-5 w-5")) + ) + ), + onClick.preventDefault.mapTo( + v + ) --> selected.writer.contramapSome, + onMouseEnter --> (_ => active.set(true)), + onMouseLeave --> (_ => active.set(false)) + ) + // More items... + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala new file mode 100644 index 0000000..6a44cce --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait HeadingsComponentsModule: + object headings: + def section(title: Node, actions: HtmlElement*): HtmlElement = + div( + cls("border-b border-gray-200 pb-5"), + div( + cls("sm:flex sm:items-center sm:justify-between"), + h3(cls("text-base font-semibold leading-6 text-gray-900"), title), + div( + cls("mt-3 flex sm:ml-4 sm:mt-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) + + def sectionWithSubtitle( + title: Node, + subtitle: Node, + actions: HtmlElement* + ): HtmlElement = + div( + cls("border-b border-gray-200 bg-white px-4 py-5 sm:px-6"), + div( + cls( + "-ml-4 -mt-4 flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls("ml-4 mt-4"), + h3( + cls("text-base font-semibold leading-6 text-gray-900"), + title + ), + p( + cls("mt-1 text-sm text-gray-500"), + subtitle + ) + ), + div( + cls("ml-4 mt-4 flex-shrink-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala new file mode 100644 index 0000000..be16e48 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala @@ -0,0 +1,321 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.codecs +import works.iterative.ui.components.laminar.CustomAttrs + +trait IconsModule: + object icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + def upload(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 24 24"), + path(d := "M0 0h24v24H0z", fill("none")), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + + def home(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 20 20"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + clipRule := "evenodd", + fillRule := "evenodd", + d := "M9.293 2.293a1 1 0 011.414 0l7 7A1 1 0 0117 11h-1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-3a1 1 0 00-1-1H9a1 1 0 00-1 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-6H3a1 1 0 01-.707-1.707l7-7z" + ) + ) + + def `x-mark-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + def `arrow-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18") + ) + ) + + def `chevron-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M15.75 19.5L8.25 12l7.5-7.5") + ) + ) + + def `chevron-right-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + clipRule("evenodd"), + fillRule("evenodd"), + d( + "M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" + ) + ) + ) + + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) + + def `paper-clip-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + cls := "flex-shrink-0 text-gray-400", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M15.621 4.379a3 3 0 00-4.242 0l-7 7a3 3 0 004.241 4.243h.001l.497-.5a.75.75 0 011.064 1.057l-.498.501-.002.002a4.5 4.5 0 01-6.364-6.364l7-7a4.5 4.5 0 016.368 6.36l-3.455 3.553A2.625 2.625 0 119.52 9.52l3.45-3.451a.75.75 0 111.061 1.06l-3.45 3.451a1.125 1.125 0 001.587 1.595l3.454-3.553a3 3 0 000-4.242z", + clipRule := "evenodd" + ) + ) + + def `exclamation-circle-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-warning`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-yellow-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-error`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-red-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z", + clipRule := "evenodd" + ) + ) + + def `alert-success`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-green-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z", + clipRule := "evenodd" + ) + ) + + def `alert-info`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-blue-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z", + clipRule := "evenodd" + ) + ) + + def spinner(mods: SvgMod*): SvgElement = svg( + withDefault(mods, cls("h-4 w-4")), + svgAttr("role", codecs.StringAsIsCodec, None) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + def arrowPath(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + fill("none"), + stroke("currentColor"), + strokeWidth("1.5"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d( + "M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" + ) + ) + ) + + def chevronUpDown(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z", + clipRule := "evenodd" + ) + ) + + def check(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z", + clipRule := "evenodd" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala new file mode 100644 index 0000000..70c4a7c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* + +trait LayoutModule: + object layout: + def cardMod: HtmlMod = cls("bg-white shadow px-4 py-5 sm:rounded-lg sm:p-6") + def card(content: Modifier[HtmlElement]*): HtmlElement = + div(cardMod, content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala new file mode 100644 index 0000000..5b64ad7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala @@ -0,0 +1,88 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait ListComponentsModule: + self: BadgeComponentsModule => + + object list: + def label( + text: String, + color: ColorKind + ): HtmlElement = badges.pill(text, color) + + def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None, + contentMod: Modifier[HtmlElement] = emptyMod + ): LI = + li( + cls("group"), + div( + contentMod, + cls( + "relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" + ), + avatar.map(a => + div( + cls("flex-shrink-0"), + div( + cls( + "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" + ), + a + ) + ) + ), + div( + cls("flex-1 min-w-0"), + p( + cls("text-sm font-medium text-gray-900"), + title, + span(cls("float-right"), right) + ), + subtitle.map(st => + p( + cls("text-sm text-gray-500 truncate"), + st + ) + ) + ) + ) + ) + + def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = + ul( + cls("relative z-0 divide-y divide-gray-200"), + role("list"), + children + ) + + def listSection( + header: String, + list: HtmlElement + ): Div = + div( + cls("relative"), + div( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + list + ) + + def navigation(sections: Modifier[HtmlElement]): HtmlElement = + navTag( + cls("flex-1 min-h-0 overflow-y-auto"), + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala new file mode 100644 index 0000000..d4a2c56 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ListContainerComponentsModule: + object listContainer: + def simpleWithDividers(items: HtmlMod*) = + ul( + role("list"), + cls("divide-y divide-gray-200"), + items.map(li(cls("py-4"), _)) + ) + + def cardWithDividers(items: HtmlMod*) = + div( + cls("overflow-hidden rounded-md bg-white shadow"), + ul( + role := "list", + cls("divide-y divide-gray-200"), + items.map( + li( + cls("px-6 py-4"), + _ + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala new file mode 100644 index 0000000..684f0fb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait ModalComponentsModule: + object modal: + def modalDialog( + content: Signal[Option[HtmlElement]], + isOpen: Signal[Boolean], + close: Observer[Unit] + ): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + onClick.preventDefault.mapTo(()) --> close + ) + ) + + div( + cls.toggle("hidden") <-- isOpen.not.combineWithFn(content)( + _ || _.isEmpty + ), + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-visible rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + child.maybe <-- content + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala new file mode 100644 index 0000000..d2bd069 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala @@ -0,0 +1,160 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind +import works.iterative.ui.components.laminar.tailwind.color.ColorWeight + +trait AlertComponentsModule: + self: IconsModule => + object alerts: + // TODO: use context functions and builder pattern to build the alerts + def alert( + message: HtmlMod, + icon: SvgElement, + color: ColorKind, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + div( + cls := "rounded-md p-4", + color(50).bg, + div( + cls := "flex", + icon, + div( + cls := "ml-3", + title.map(t => + h3( + cls := "text-sm font-medium", + color(800).text, + t + ) + ), + div( + cls := "text-sm", + title.map(_ => cls("mt-2")), + color(700).text, + message + ), + actions.map { act => + div( + cls := "mt-4", + act(color) + ) + } + ), + mods(color) + ) + ) + + def warning( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-warning`(), + ColorKind.yellow, + title = title, + actions = actions, + mods = mods + ) + + def error( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-error`(), + ColorKind.red, + title = title, + actions = actions, + mods = mods + ) + + def success( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-success`(), + ColorKind.green, + title = title, + actions = actions, + mods = mods + ) + + def info( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-info`(), + ColorKind.blue, + title = title, + actions = actions, + mods = mods + ) + + def buttons(buttons: (ColorKind => HtmlMod)*): ColorKind => HtmlMod = + color => + div( + cls("-mx-2 -my-1.5 flex"), + buttons.map(_(color)) + ) + + def button(title: String)(mods: HtmlMod*): ColorKind => HtmlMod = color => + L.button( + tpe("button"), + cls( + "first:ml-0 ml-3 rounded-md px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(800).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + title, + mods + ) + + def closeMod(mods: HtmlMod): ColorKind => HtmlMod = color => + val closeIcon: SvgElement = + import svg.* + svg( + cls := "h-5 w-5", + viewBox := "0 0 20 20", + fill := "currentColor", + path( + d := "M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" + ) + ) + div( + cls("ml-auto pl-3"), + L.button( + cls( + "-mx-1.5 -my-1.5 inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(500).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + mods, + aria.hidden := true, + closeIcon + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala new file mode 100644 index 0000000..fa374fe --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala @@ -0,0 +1,52 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait AppShellComponentsModule: + object shell: + object stackedLight extends StackedAppShell: + override def navCls = cls("border-b border-gray-200 bg-white") + override def headerCls = emptyMod + override def pageWrapper(content: HtmlMod*): HtmlMod = + div(cls("py-10"), content) + + object stackedBranded extends StackedAppShell: + override def navCls = cls("bg-indigo-600") + override def headerCls = cls("bg-white shadown-sm") + override def pageWrapper(content: HtmlMod*): HtmlMod = content + +trait StackedAppShell: + protected def navCls: HtmlMod + protected def headerCls: HtmlMod + protected def pageWrapper(content: HtmlMod*): HtmlMod + + def apply(pageTitle: HtmlMod)(navbarItems: HtmlMod*)( + content: HtmlMod* + ): HtmlElement = + div( + cls("min-h-full"), + navTag( + navCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + div(cls("flex h-16 items-center justify-between"), navbarItems) + ) + ), + pageWrapper( + headerTag( + headerCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + h1( + cls( + "text-3xl font-bold leading-tight tracking-tight text-gray-900" + ), + pageTitle + ) + ) + ), + mainTag( + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala new file mode 100644 index 0000000..f6d7d7f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait BadgeComponentsModule: + + object badges: + def pill(name: String, color: ColorKind): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + name + ) + + def pill(name: String, color: Signal[ColorKind]): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + cls <-- color.map(_.apply(800).text.toCSS), + cls <-- color.map(_.apply(100).bg.toCSS), + name + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala new file mode 100644 index 0000000..03a2c9c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait BreadcrumbsModule: + self: IconsModule => + + object breadcrumbs: + def container(mods: HtmlMod*): HtmlElement = + navTag(cls("flex"), aria.label("Breadcrumb"), mods) + + def list(mods: HtmlMod*): HtmlElement = + ol(cls("flex items-center space-x-4"), role("list"), mods) + + def homeItem(mods: HtmlMod*): HtmlElement = + li( + div( + a( + href("#"), + cls("text-gray-400 hover:text-gray-500"), + icons.home(svg.cls("h-5 w-5 flex-shrink-0")), + span(cls("sr-only"), "Home"), + mods + ) + ) + ) + + def item(mods: HtmlMod*): HtmlElement = + li( + div( + cls("flex items-center"), + icons.`chevron-right-solid`( + svg.cls("h-5 w-5 flex-shrink-0 text-gray-400") + ), + a( + href("#"), + cls("ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala new file mode 100644 index 0000000..27f3cf2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala @@ -0,0 +1,102 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +trait ButtonComponentsModule: + + object buttons: + + private inline def srHelp(text: String): Modifier[HtmlElement] = + span(cls := "sr-only", text) + + val sharedButtonClasses = + "inline-flex justify-center py-2 px-4 border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + + val sharedButtonMod: HtmlMod = cls(sharedButtonClasses) + + val primaryButtonClasses = + "border-transparent text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300" + val primaryButtonMod: HtmlMod = cls(primaryButtonClasses) + + val secondaryButtonClasses = + "border-gray-300 text-gray-700 hover:bg-gray-50" + val secondaryButtonMod: HtmlMod = cls(secondaryButtonClasses) + + val neutralButtonClasses = + "border-gray-300 text-gray-700 bg-white hover:bg-gray-50" + val neutralButtonMod: HtmlMod = cls(neutralButtonClasses) + + val positiveButtonClasses = + "border-transparent text-white bg-green-600 hover:bg-green-700" + val positiveButtonMod: HtmlMod = cls(positiveButtonClasses) + + val negativeButtonClasses = + "border-transparent text-white bg-red-600 hover:bg-red-700" + val negativeButtonMod: HtmlMod = cls(negativeButtonClasses) + + def button( + text: Modifier[HtmlElement], + id: Option[String], + icon: Option[SvgElement] = None, + buttonType: String = "submit", + primary: Boolean = false + )(mods: HtmlMod*): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe(buttonType), + sharedButtonMod, + if primary then primaryButtonMod else secondaryButtonMod, + icon, + text, + mods + ) + + def primaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "submit" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, true)(mods*) + + def secondaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "button" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, false)(mods*) + + def inlineButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srText.map(srHelp(_)), + icon + ) + + def iconButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srText.map(srHelp(_)), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala new file mode 100644 index 0000000..ae4ab86 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala @@ -0,0 +1,56 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +import io.laminext.syntax.core.* + +trait ComboboxModule: + self: IconsModule => + + object combobox: + object simple: + def container(mods: HtmlMod*): Div = div(cls("relative mt-2"), mods) + + def button(mods: HtmlMod*): Button = + L.button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + mods + ) + + def options(mods: HtmlMod*) = + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + role := "listbox", + mods + ) + + def option(isActive: Signal[Boolean], isSelected: Signal[Boolean])( + mods: HtmlMod* + ) = + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- isActive, + cls.toggle("text-gray-900") <-- isActive.not, + cls.toggle("font-semibold") <-- isSelected, + mods + ) + + def optionValue(l: String): HtmlElement = + span(cls("block truncate"), l) + + def checkmark(isActive: Signal[Boolean]): HtmlElement = + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- isActive.not, + cls.toggle("text-white") <-- isActive, + icons.check(svg.cls("h-5 w-5")) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala new file mode 100644 index 0000000..f5a8873 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ContainerComponentsModule: + object container: + /** Full-width on mobile, constrained with padded content above */ + def default(content: HtmlMod*): HtmlElement = + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + + def padded(content: HtmlMod*): HtmlElement = + default(cls("px-4"), content) + + def narrow(content: HtmlMod*): HtmlElement = + padded( + div(cls("mx-auto max-w-3xl"), content) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala new file mode 100644 index 0000000..d601ab6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala @@ -0,0 +1,117 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.core.FileRef +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.laminar.* +import works.iterative.core.UserMessage + +trait DetailComponentsModule: + self: IconsModule => + object details: + def sectionHeader( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls( + "flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls := "px-4 sm:px-0", + h3( + cls := "text-base font-semibold leading-7 text-gray-900", + title + ), + subtitle.map(st => + p( + cls := "mt-1 max-w-2xl text-sm leading-6 text-gray-500", + st + ) + ) + ), + div(cls("flex-shrink-0"), actions) + ) + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + )(content: HtmlMod): HtmlElement = + div(sectionHeader(title, subtitle, actions), content) + + def fields(items: Node*): HtmlElement = + div( + dl( + cls := "divide-y divide-gray-100", + items + ) + ) + + def field( + title: Node, + content: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "px-2 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0", + dt( + cls := "text-sm font-medium leading-6 text-gray-900", + title + ), + dd( + cls := "mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0", + content + ) + ) + + def files( + fs: Seq[FileRef], + fileMods: Option[(FileRef, Int) => HtmlMod] = None + )( + mods: HtmlMod* + )(using ComponentContext[?]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-100 rounded-md border border-gray-200", + fs.zipWithIndex + .map((f, i) => + fileMods match + case Some(fm) => file(f)(fm(f, i)) + case _ => file(f)() + ), + mods + ) + + def file(f: FileRef)(mods: HtmlMod*)(using + ComponentContext[?] + ): HtmlElement = + li( + cls := "flex items-center justify-between py-4 pl-4 pr-5 text-sm leading-6", + div( + cls := "flex w-0 flex-1 items-center", + icons.`paper-clip-solid`(), + div( + cls := "ml-4 flex min-w-0 flex-1 gap-2", + span( + cls := "truncate font-medium", + f.name + ), + f.sizeString.map(size => + span(cls := "flex-shrink-0 text-gray-400", size) + ) + ) + ), + div( + cls := "ml-4 flex-shrink-0", + a( + href := f.url, + target := "_blank", + cls := "font-medium text-indigo-600 hover:text-indigo-500", + UserMessage("file.download").asString + ) + ), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala new file mode 100644 index 0000000..e9d7f1b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala @@ -0,0 +1,202 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html +import io.laminext.syntax.core.* + +trait FormComponentsModule: + self: IconsModule => + + object forms: + + val inputClasses = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls("space-y-6 sm:space-y-5"), + div( + h3(cls("text-lg leading-6 font-medium text-gray-900"), title), + subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) + ), + div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) + ) + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: Modifier[HtmlElement]* + ): ReactiveHtmlElement[html.Label] = + L.label( + cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), + forId.map(id => L.forId(id)), + labelText, + if required then sup(cls("text-gray-400"), "* povinné pole") + else emptyMod, + mods + ) + + def field( + label: Modifier[HtmlElement] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls( + "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5" + ), + label, + div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) + ) + + def field( + id: String, + labelText: String, + input: HtmlElement, + help: Option[String] + ): HtmlElement = + field( + label(labelText, Some(id))() + )( + input.amend(idAttr(id)), + help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) + ) + + def form(mods: Modifier[HtmlElement]*)( + sections: Modifier[HtmlElement]* + )(actions: Modifier[HtmlElement]*): ReactiveHtmlElement[html.Form] = + L.form( + cls("space-y-8 divide-y divide-gray-200"), + mods, + sections, + div( + cls("pt-5"), + div(cls("flex justify-end"), actions) + ) + ) + + def inlineForm( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + def errorTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-red-600") + + def validationError(text: Modifier[HtmlElement]): HtmlElement = + p(errorTextMods, text) + + def helpTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-gray-500") + + def fieldHelp(text: Modifier[HtmlElement]): HtmlElement = + p(helpTextMods, text) + + def inputField( + id: String, + labelText: String, + placeholderText: Option[String] = None, + inputType: String = "text", + helpText: Option[String] = None + ): HtmlElement = + field( + id, + labelText, + input(id, inputType, placeholderText)(), + helpText + ) + + def input( + id: String, + inputType: String = "text", + placeholderText: Option[String] = None + )(mods: HtmlMod*): HtmlElement = + L.input( + cls(inputClasses), + idAttr(id), + nameAttr(id), + placeholderText.map(placeholder(_)), + tpe(inputType), + mods + ) + + def comboBoxSimple( + options: List[(String, String)], + selectedInitially: Option[String] = None, + id: Option[String] = None, + name: Option[String] = None + ): HtmlElement = + val expanded = Var(false) + val selected = Var(selectedInitially) + div( + cls("relative mt-2"), + L.input( + cls( + "w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + id.map(idAttr(_)), + name.map(nameAttr(_)), + tpe("text"), + role("combobox"), + aria.controls("options"), + aria.expanded <-- expanded + ), + button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + onClick.preventDefault --> (_ => expanded.toggle()) + ), + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + cls.toggle("hidden") <-- expanded.signal.not, + id.map(i => idAttr(s"${i}-options")), + role := "listbox", + // Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation. + // Active: "text-white bg-indigo-600", Not Active: "text-gray-900" + for (((v, l), i) <- options.zipWithIndex) + yield + val active = Var(false) + val isSelected = selected.signal.map(_.contains(v)) + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- active.signal, + cls.toggle("text-gray-900") <-- active.signal.not, + cls.toggle("font-semibold") <-- isSelected, + id.map(cid => idAttr(s"${cid}-option-${i}")), + role := "option", + tabIndex := -1, + // Selected: "font-semibold" + span(cls("block truncate"), l), + // Checkmark, only display for selected option. + // Active: "text-white", Not Active: "text-indigo-600" + isSelected.childWhenTrue( + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- active.signal.not, + cls.toggle("text-white") <-- active.signal, + icons.check(svg.cls("h-5 w-5")) + ) + ), + onClick.preventDefault.mapTo( + v + ) --> selected.writer.contramapSome, + onMouseEnter --> (_ => active.set(true)), + onMouseLeave --> (_ => active.set(false)) + ) + // More items... + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala new file mode 100644 index 0000000..6a44cce --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait HeadingsComponentsModule: + object headings: + def section(title: Node, actions: HtmlElement*): HtmlElement = + div( + cls("border-b border-gray-200 pb-5"), + div( + cls("sm:flex sm:items-center sm:justify-between"), + h3(cls("text-base font-semibold leading-6 text-gray-900"), title), + div( + cls("mt-3 flex sm:ml-4 sm:mt-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) + + def sectionWithSubtitle( + title: Node, + subtitle: Node, + actions: HtmlElement* + ): HtmlElement = + div( + cls("border-b border-gray-200 bg-white px-4 py-5 sm:px-6"), + div( + cls( + "-ml-4 -mt-4 flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls("ml-4 mt-4"), + h3( + cls("text-base font-semibold leading-6 text-gray-900"), + title + ), + p( + cls("mt-1 text-sm text-gray-500"), + subtitle + ) + ), + div( + cls("ml-4 mt-4 flex-shrink-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala new file mode 100644 index 0000000..be16e48 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala @@ -0,0 +1,321 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.codecs +import works.iterative.ui.components.laminar.CustomAttrs + +trait IconsModule: + object icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + def upload(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 24 24"), + path(d := "M0 0h24v24H0z", fill("none")), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + + def home(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 20 20"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + clipRule := "evenodd", + fillRule := "evenodd", + d := "M9.293 2.293a1 1 0 011.414 0l7 7A1 1 0 0117 11h-1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-3a1 1 0 00-1-1H9a1 1 0 00-1 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-6H3a1 1 0 01-.707-1.707l7-7z" + ) + ) + + def `x-mark-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + def `arrow-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18") + ) + ) + + def `chevron-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M15.75 19.5L8.25 12l7.5-7.5") + ) + ) + + def `chevron-right-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + clipRule("evenodd"), + fillRule("evenodd"), + d( + "M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" + ) + ) + ) + + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) + + def `paper-clip-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + cls := "flex-shrink-0 text-gray-400", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M15.621 4.379a3 3 0 00-4.242 0l-7 7a3 3 0 004.241 4.243h.001l.497-.5a.75.75 0 011.064 1.057l-.498.501-.002.002a4.5 4.5 0 01-6.364-6.364l7-7a4.5 4.5 0 016.368 6.36l-3.455 3.553A2.625 2.625 0 119.52 9.52l3.45-3.451a.75.75 0 111.061 1.06l-3.45 3.451a1.125 1.125 0 001.587 1.595l3.454-3.553a3 3 0 000-4.242z", + clipRule := "evenodd" + ) + ) + + def `exclamation-circle-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-warning`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-yellow-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-error`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-red-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z", + clipRule := "evenodd" + ) + ) + + def `alert-success`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-green-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z", + clipRule := "evenodd" + ) + ) + + def `alert-info`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-blue-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z", + clipRule := "evenodd" + ) + ) + + def spinner(mods: SvgMod*): SvgElement = svg( + withDefault(mods, cls("h-4 w-4")), + svgAttr("role", codecs.StringAsIsCodec, None) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + def arrowPath(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + fill("none"), + stroke("currentColor"), + strokeWidth("1.5"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d( + "M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" + ) + ) + ) + + def chevronUpDown(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z", + clipRule := "evenodd" + ) + ) + + def check(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z", + clipRule := "evenodd" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala new file mode 100644 index 0000000..70c4a7c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* + +trait LayoutModule: + object layout: + def cardMod: HtmlMod = cls("bg-white shadow px-4 py-5 sm:rounded-lg sm:p-6") + def card(content: Modifier[HtmlElement]*): HtmlElement = + div(cardMod, content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala new file mode 100644 index 0000000..5b64ad7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala @@ -0,0 +1,88 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait ListComponentsModule: + self: BadgeComponentsModule => + + object list: + def label( + text: String, + color: ColorKind + ): HtmlElement = badges.pill(text, color) + + def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None, + contentMod: Modifier[HtmlElement] = emptyMod + ): LI = + li( + cls("group"), + div( + contentMod, + cls( + "relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" + ), + avatar.map(a => + div( + cls("flex-shrink-0"), + div( + cls( + "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" + ), + a + ) + ) + ), + div( + cls("flex-1 min-w-0"), + p( + cls("text-sm font-medium text-gray-900"), + title, + span(cls("float-right"), right) + ), + subtitle.map(st => + p( + cls("text-sm text-gray-500 truncate"), + st + ) + ) + ) + ) + ) + + def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = + ul( + cls("relative z-0 divide-y divide-gray-200"), + role("list"), + children + ) + + def listSection( + header: String, + list: HtmlElement + ): Div = + div( + cls("relative"), + div( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + list + ) + + def navigation(sections: Modifier[HtmlElement]): HtmlElement = + navTag( + cls("flex-1 min-h-0 overflow-y-auto"), + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala new file mode 100644 index 0000000..d4a2c56 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ListContainerComponentsModule: + object listContainer: + def simpleWithDividers(items: HtmlMod*) = + ul( + role("list"), + cls("divide-y divide-gray-200"), + items.map(li(cls("py-4"), _)) + ) + + def cardWithDividers(items: HtmlMod*) = + div( + cls("overflow-hidden rounded-md bg-white shadow"), + ul( + role := "list", + cls("divide-y divide-gray-200"), + items.map( + li( + cls("px-6 py-4"), + _ + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala new file mode 100644 index 0000000..684f0fb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait ModalComponentsModule: + object modal: + def modalDialog( + content: Signal[Option[HtmlElement]], + isOpen: Signal[Boolean], + close: Observer[Unit] + ): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + onClick.preventDefault.mapTo(()) --> close + ) + ) + + div( + cls.toggle("hidden") <-- isOpen.not.combineWithFn(content)( + _ || _.isEmpty + ), + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-visible rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + child.maybe <-- content + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala new file mode 100644 index 0000000..866b529 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala @@ -0,0 +1,48 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* + +trait PageComponentsModule: + + object page: + def container( + children: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8 overflow-y-auto"), + children + ) + + def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement = + div( + cls("p-8 bg-gray-100 h-full"), + header, + children + ) + + def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + div( + cls("pb-5 border-b border-gray-200"), + div(cls("float-right"), right), + h1( + cls("text-2xl leading-6 font-medium text-gray-900"), + title + ), + subtitle.map( + p( + cls("text-sm font-medium text-gray-500"), + _ + ) + ) + ) + + def clickable: Modifier[HtmlElement] = + cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala new file mode 100644 index 0000000..d2bd069 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala @@ -0,0 +1,160 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind +import works.iterative.ui.components.laminar.tailwind.color.ColorWeight + +trait AlertComponentsModule: + self: IconsModule => + object alerts: + // TODO: use context functions and builder pattern to build the alerts + def alert( + message: HtmlMod, + icon: SvgElement, + color: ColorKind, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + div( + cls := "rounded-md p-4", + color(50).bg, + div( + cls := "flex", + icon, + div( + cls := "ml-3", + title.map(t => + h3( + cls := "text-sm font-medium", + color(800).text, + t + ) + ), + div( + cls := "text-sm", + title.map(_ => cls("mt-2")), + color(700).text, + message + ), + actions.map { act => + div( + cls := "mt-4", + act(color) + ) + } + ), + mods(color) + ) + ) + + def warning( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-warning`(), + ColorKind.yellow, + title = title, + actions = actions, + mods = mods + ) + + def error( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-error`(), + ColorKind.red, + title = title, + actions = actions, + mods = mods + ) + + def success( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-success`(), + ColorKind.green, + title = title, + actions = actions, + mods = mods + ) + + def info( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-info`(), + ColorKind.blue, + title = title, + actions = actions, + mods = mods + ) + + def buttons(buttons: (ColorKind => HtmlMod)*): ColorKind => HtmlMod = + color => + div( + cls("-mx-2 -my-1.5 flex"), + buttons.map(_(color)) + ) + + def button(title: String)(mods: HtmlMod*): ColorKind => HtmlMod = color => + L.button( + tpe("button"), + cls( + "first:ml-0 ml-3 rounded-md px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(800).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + title, + mods + ) + + def closeMod(mods: HtmlMod): ColorKind => HtmlMod = color => + val closeIcon: SvgElement = + import svg.* + svg( + cls := "h-5 w-5", + viewBox := "0 0 20 20", + fill := "currentColor", + path( + d := "M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" + ) + ) + div( + cls("ml-auto pl-3"), + L.button( + cls( + "-mx-1.5 -my-1.5 inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(500).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + mods, + aria.hidden := true, + closeIcon + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala new file mode 100644 index 0000000..fa374fe --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala @@ -0,0 +1,52 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait AppShellComponentsModule: + object shell: + object stackedLight extends StackedAppShell: + override def navCls = cls("border-b border-gray-200 bg-white") + override def headerCls = emptyMod + override def pageWrapper(content: HtmlMod*): HtmlMod = + div(cls("py-10"), content) + + object stackedBranded extends StackedAppShell: + override def navCls = cls("bg-indigo-600") + override def headerCls = cls("bg-white shadown-sm") + override def pageWrapper(content: HtmlMod*): HtmlMod = content + +trait StackedAppShell: + protected def navCls: HtmlMod + protected def headerCls: HtmlMod + protected def pageWrapper(content: HtmlMod*): HtmlMod + + def apply(pageTitle: HtmlMod)(navbarItems: HtmlMod*)( + content: HtmlMod* + ): HtmlElement = + div( + cls("min-h-full"), + navTag( + navCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + div(cls("flex h-16 items-center justify-between"), navbarItems) + ) + ), + pageWrapper( + headerTag( + headerCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + h1( + cls( + "text-3xl font-bold leading-tight tracking-tight text-gray-900" + ), + pageTitle + ) + ) + ), + mainTag( + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala new file mode 100644 index 0000000..f6d7d7f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait BadgeComponentsModule: + + object badges: + def pill(name: String, color: ColorKind): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + name + ) + + def pill(name: String, color: Signal[ColorKind]): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + cls <-- color.map(_.apply(800).text.toCSS), + cls <-- color.map(_.apply(100).bg.toCSS), + name + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala new file mode 100644 index 0000000..03a2c9c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait BreadcrumbsModule: + self: IconsModule => + + object breadcrumbs: + def container(mods: HtmlMod*): HtmlElement = + navTag(cls("flex"), aria.label("Breadcrumb"), mods) + + def list(mods: HtmlMod*): HtmlElement = + ol(cls("flex items-center space-x-4"), role("list"), mods) + + def homeItem(mods: HtmlMod*): HtmlElement = + li( + div( + a( + href("#"), + cls("text-gray-400 hover:text-gray-500"), + icons.home(svg.cls("h-5 w-5 flex-shrink-0")), + span(cls("sr-only"), "Home"), + mods + ) + ) + ) + + def item(mods: HtmlMod*): HtmlElement = + li( + div( + cls("flex items-center"), + icons.`chevron-right-solid`( + svg.cls("h-5 w-5 flex-shrink-0 text-gray-400") + ), + a( + href("#"), + cls("ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala new file mode 100644 index 0000000..27f3cf2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala @@ -0,0 +1,102 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +trait ButtonComponentsModule: + + object buttons: + + private inline def srHelp(text: String): Modifier[HtmlElement] = + span(cls := "sr-only", text) + + val sharedButtonClasses = + "inline-flex justify-center py-2 px-4 border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + + val sharedButtonMod: HtmlMod = cls(sharedButtonClasses) + + val primaryButtonClasses = + "border-transparent text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300" + val primaryButtonMod: HtmlMod = cls(primaryButtonClasses) + + val secondaryButtonClasses = + "border-gray-300 text-gray-700 hover:bg-gray-50" + val secondaryButtonMod: HtmlMod = cls(secondaryButtonClasses) + + val neutralButtonClasses = + "border-gray-300 text-gray-700 bg-white hover:bg-gray-50" + val neutralButtonMod: HtmlMod = cls(neutralButtonClasses) + + val positiveButtonClasses = + "border-transparent text-white bg-green-600 hover:bg-green-700" + val positiveButtonMod: HtmlMod = cls(positiveButtonClasses) + + val negativeButtonClasses = + "border-transparent text-white bg-red-600 hover:bg-red-700" + val negativeButtonMod: HtmlMod = cls(negativeButtonClasses) + + def button( + text: Modifier[HtmlElement], + id: Option[String], + icon: Option[SvgElement] = None, + buttonType: String = "submit", + primary: Boolean = false + )(mods: HtmlMod*): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe(buttonType), + sharedButtonMod, + if primary then primaryButtonMod else secondaryButtonMod, + icon, + text, + mods + ) + + def primaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "submit" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, true)(mods*) + + def secondaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "button" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, false)(mods*) + + def inlineButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srText.map(srHelp(_)), + icon + ) + + def iconButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srText.map(srHelp(_)), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala new file mode 100644 index 0000000..ae4ab86 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala @@ -0,0 +1,56 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +import io.laminext.syntax.core.* + +trait ComboboxModule: + self: IconsModule => + + object combobox: + object simple: + def container(mods: HtmlMod*): Div = div(cls("relative mt-2"), mods) + + def button(mods: HtmlMod*): Button = + L.button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + mods + ) + + def options(mods: HtmlMod*) = + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + role := "listbox", + mods + ) + + def option(isActive: Signal[Boolean], isSelected: Signal[Boolean])( + mods: HtmlMod* + ) = + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- isActive, + cls.toggle("text-gray-900") <-- isActive.not, + cls.toggle("font-semibold") <-- isSelected, + mods + ) + + def optionValue(l: String): HtmlElement = + span(cls("block truncate"), l) + + def checkmark(isActive: Signal[Boolean]): HtmlElement = + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- isActive.not, + cls.toggle("text-white") <-- isActive, + icons.check(svg.cls("h-5 w-5")) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala new file mode 100644 index 0000000..f5a8873 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ContainerComponentsModule: + object container: + /** Full-width on mobile, constrained with padded content above */ + def default(content: HtmlMod*): HtmlElement = + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + + def padded(content: HtmlMod*): HtmlElement = + default(cls("px-4"), content) + + def narrow(content: HtmlMod*): HtmlElement = + padded( + div(cls("mx-auto max-w-3xl"), content) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala new file mode 100644 index 0000000..d601ab6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala @@ -0,0 +1,117 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.core.FileRef +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.laminar.* +import works.iterative.core.UserMessage + +trait DetailComponentsModule: + self: IconsModule => + object details: + def sectionHeader( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls( + "flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls := "px-4 sm:px-0", + h3( + cls := "text-base font-semibold leading-7 text-gray-900", + title + ), + subtitle.map(st => + p( + cls := "mt-1 max-w-2xl text-sm leading-6 text-gray-500", + st + ) + ) + ), + div(cls("flex-shrink-0"), actions) + ) + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + )(content: HtmlMod): HtmlElement = + div(sectionHeader(title, subtitle, actions), content) + + def fields(items: Node*): HtmlElement = + div( + dl( + cls := "divide-y divide-gray-100", + items + ) + ) + + def field( + title: Node, + content: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "px-2 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0", + dt( + cls := "text-sm font-medium leading-6 text-gray-900", + title + ), + dd( + cls := "mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0", + content + ) + ) + + def files( + fs: Seq[FileRef], + fileMods: Option[(FileRef, Int) => HtmlMod] = None + )( + mods: HtmlMod* + )(using ComponentContext[?]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-100 rounded-md border border-gray-200", + fs.zipWithIndex + .map((f, i) => + fileMods match + case Some(fm) => file(f)(fm(f, i)) + case _ => file(f)() + ), + mods + ) + + def file(f: FileRef)(mods: HtmlMod*)(using + ComponentContext[?] + ): HtmlElement = + li( + cls := "flex items-center justify-between py-4 pl-4 pr-5 text-sm leading-6", + div( + cls := "flex w-0 flex-1 items-center", + icons.`paper-clip-solid`(), + div( + cls := "ml-4 flex min-w-0 flex-1 gap-2", + span( + cls := "truncate font-medium", + f.name + ), + f.sizeString.map(size => + span(cls := "flex-shrink-0 text-gray-400", size) + ) + ) + ), + div( + cls := "ml-4 flex-shrink-0", + a( + href := f.url, + target := "_blank", + cls := "font-medium text-indigo-600 hover:text-indigo-500", + UserMessage("file.download").asString + ) + ), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala new file mode 100644 index 0000000..e9d7f1b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala @@ -0,0 +1,202 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html +import io.laminext.syntax.core.* + +trait FormComponentsModule: + self: IconsModule => + + object forms: + + val inputClasses = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls("space-y-6 sm:space-y-5"), + div( + h3(cls("text-lg leading-6 font-medium text-gray-900"), title), + subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) + ), + div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) + ) + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: Modifier[HtmlElement]* + ): ReactiveHtmlElement[html.Label] = + L.label( + cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), + forId.map(id => L.forId(id)), + labelText, + if required then sup(cls("text-gray-400"), "* povinné pole") + else emptyMod, + mods + ) + + def field( + label: Modifier[HtmlElement] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls( + "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5" + ), + label, + div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) + ) + + def field( + id: String, + labelText: String, + input: HtmlElement, + help: Option[String] + ): HtmlElement = + field( + label(labelText, Some(id))() + )( + input.amend(idAttr(id)), + help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) + ) + + def form(mods: Modifier[HtmlElement]*)( + sections: Modifier[HtmlElement]* + )(actions: Modifier[HtmlElement]*): ReactiveHtmlElement[html.Form] = + L.form( + cls("space-y-8 divide-y divide-gray-200"), + mods, + sections, + div( + cls("pt-5"), + div(cls("flex justify-end"), actions) + ) + ) + + def inlineForm( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + def errorTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-red-600") + + def validationError(text: Modifier[HtmlElement]): HtmlElement = + p(errorTextMods, text) + + def helpTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-gray-500") + + def fieldHelp(text: Modifier[HtmlElement]): HtmlElement = + p(helpTextMods, text) + + def inputField( + id: String, + labelText: String, + placeholderText: Option[String] = None, + inputType: String = "text", + helpText: Option[String] = None + ): HtmlElement = + field( + id, + labelText, + input(id, inputType, placeholderText)(), + helpText + ) + + def input( + id: String, + inputType: String = "text", + placeholderText: Option[String] = None + )(mods: HtmlMod*): HtmlElement = + L.input( + cls(inputClasses), + idAttr(id), + nameAttr(id), + placeholderText.map(placeholder(_)), + tpe(inputType), + mods + ) + + def comboBoxSimple( + options: List[(String, String)], + selectedInitially: Option[String] = None, + id: Option[String] = None, + name: Option[String] = None + ): HtmlElement = + val expanded = Var(false) + val selected = Var(selectedInitially) + div( + cls("relative mt-2"), + L.input( + cls( + "w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + id.map(idAttr(_)), + name.map(nameAttr(_)), + tpe("text"), + role("combobox"), + aria.controls("options"), + aria.expanded <-- expanded + ), + button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + onClick.preventDefault --> (_ => expanded.toggle()) + ), + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + cls.toggle("hidden") <-- expanded.signal.not, + id.map(i => idAttr(s"${i}-options")), + role := "listbox", + // Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation. + // Active: "text-white bg-indigo-600", Not Active: "text-gray-900" + for (((v, l), i) <- options.zipWithIndex) + yield + val active = Var(false) + val isSelected = selected.signal.map(_.contains(v)) + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- active.signal, + cls.toggle("text-gray-900") <-- active.signal.not, + cls.toggle("font-semibold") <-- isSelected, + id.map(cid => idAttr(s"${cid}-option-${i}")), + role := "option", + tabIndex := -1, + // Selected: "font-semibold" + span(cls("block truncate"), l), + // Checkmark, only display for selected option. + // Active: "text-white", Not Active: "text-indigo-600" + isSelected.childWhenTrue( + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- active.signal.not, + cls.toggle("text-white") <-- active.signal, + icons.check(svg.cls("h-5 w-5")) + ) + ), + onClick.preventDefault.mapTo( + v + ) --> selected.writer.contramapSome, + onMouseEnter --> (_ => active.set(true)), + onMouseLeave --> (_ => active.set(false)) + ) + // More items... + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala new file mode 100644 index 0000000..6a44cce --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait HeadingsComponentsModule: + object headings: + def section(title: Node, actions: HtmlElement*): HtmlElement = + div( + cls("border-b border-gray-200 pb-5"), + div( + cls("sm:flex sm:items-center sm:justify-between"), + h3(cls("text-base font-semibold leading-6 text-gray-900"), title), + div( + cls("mt-3 flex sm:ml-4 sm:mt-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) + + def sectionWithSubtitle( + title: Node, + subtitle: Node, + actions: HtmlElement* + ): HtmlElement = + div( + cls("border-b border-gray-200 bg-white px-4 py-5 sm:px-6"), + div( + cls( + "-ml-4 -mt-4 flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls("ml-4 mt-4"), + h3( + cls("text-base font-semibold leading-6 text-gray-900"), + title + ), + p( + cls("mt-1 text-sm text-gray-500"), + subtitle + ) + ), + div( + cls("ml-4 mt-4 flex-shrink-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala new file mode 100644 index 0000000..be16e48 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala @@ -0,0 +1,321 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.codecs +import works.iterative.ui.components.laminar.CustomAttrs + +trait IconsModule: + object icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + def upload(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 24 24"), + path(d := "M0 0h24v24H0z", fill("none")), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + + def home(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 20 20"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + clipRule := "evenodd", + fillRule := "evenodd", + d := "M9.293 2.293a1 1 0 011.414 0l7 7A1 1 0 0117 11h-1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-3a1 1 0 00-1-1H9a1 1 0 00-1 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-6H3a1 1 0 01-.707-1.707l7-7z" + ) + ) + + def `x-mark-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + def `arrow-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18") + ) + ) + + def `chevron-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M15.75 19.5L8.25 12l7.5-7.5") + ) + ) + + def `chevron-right-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + clipRule("evenodd"), + fillRule("evenodd"), + d( + "M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" + ) + ) + ) + + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) + + def `paper-clip-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + cls := "flex-shrink-0 text-gray-400", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M15.621 4.379a3 3 0 00-4.242 0l-7 7a3 3 0 004.241 4.243h.001l.497-.5a.75.75 0 011.064 1.057l-.498.501-.002.002a4.5 4.5 0 01-6.364-6.364l7-7a4.5 4.5 0 016.368 6.36l-3.455 3.553A2.625 2.625 0 119.52 9.52l3.45-3.451a.75.75 0 111.061 1.06l-3.45 3.451a1.125 1.125 0 001.587 1.595l3.454-3.553a3 3 0 000-4.242z", + clipRule := "evenodd" + ) + ) + + def `exclamation-circle-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-warning`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-yellow-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-error`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-red-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z", + clipRule := "evenodd" + ) + ) + + def `alert-success`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-green-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z", + clipRule := "evenodd" + ) + ) + + def `alert-info`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-blue-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z", + clipRule := "evenodd" + ) + ) + + def spinner(mods: SvgMod*): SvgElement = svg( + withDefault(mods, cls("h-4 w-4")), + svgAttr("role", codecs.StringAsIsCodec, None) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + def arrowPath(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + fill("none"), + stroke("currentColor"), + strokeWidth("1.5"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d( + "M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" + ) + ) + ) + + def chevronUpDown(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z", + clipRule := "evenodd" + ) + ) + + def check(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z", + clipRule := "evenodd" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala new file mode 100644 index 0000000..70c4a7c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* + +trait LayoutModule: + object layout: + def cardMod: HtmlMod = cls("bg-white shadow px-4 py-5 sm:rounded-lg sm:p-6") + def card(content: Modifier[HtmlElement]*): HtmlElement = + div(cardMod, content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala new file mode 100644 index 0000000..5b64ad7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala @@ -0,0 +1,88 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait ListComponentsModule: + self: BadgeComponentsModule => + + object list: + def label( + text: String, + color: ColorKind + ): HtmlElement = badges.pill(text, color) + + def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None, + contentMod: Modifier[HtmlElement] = emptyMod + ): LI = + li( + cls("group"), + div( + contentMod, + cls( + "relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" + ), + avatar.map(a => + div( + cls("flex-shrink-0"), + div( + cls( + "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" + ), + a + ) + ) + ), + div( + cls("flex-1 min-w-0"), + p( + cls("text-sm font-medium text-gray-900"), + title, + span(cls("float-right"), right) + ), + subtitle.map(st => + p( + cls("text-sm text-gray-500 truncate"), + st + ) + ) + ) + ) + ) + + def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = + ul( + cls("relative z-0 divide-y divide-gray-200"), + role("list"), + children + ) + + def listSection( + header: String, + list: HtmlElement + ): Div = + div( + cls("relative"), + div( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + list + ) + + def navigation(sections: Modifier[HtmlElement]): HtmlElement = + navTag( + cls("flex-1 min-h-0 overflow-y-auto"), + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala new file mode 100644 index 0000000..d4a2c56 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ListContainerComponentsModule: + object listContainer: + def simpleWithDividers(items: HtmlMod*) = + ul( + role("list"), + cls("divide-y divide-gray-200"), + items.map(li(cls("py-4"), _)) + ) + + def cardWithDividers(items: HtmlMod*) = + div( + cls("overflow-hidden rounded-md bg-white shadow"), + ul( + role := "list", + cls("divide-y divide-gray-200"), + items.map( + li( + cls("px-6 py-4"), + _ + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala new file mode 100644 index 0000000..684f0fb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait ModalComponentsModule: + object modal: + def modalDialog( + content: Signal[Option[HtmlElement]], + isOpen: Signal[Boolean], + close: Observer[Unit] + ): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + onClick.preventDefault.mapTo(()) --> close + ) + ) + + div( + cls.toggle("hidden") <-- isOpen.not.combineWithFn(content)( + _ || _.isEmpty + ), + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-visible rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + child.maybe <-- content + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala new file mode 100644 index 0000000..866b529 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala @@ -0,0 +1,48 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* + +trait PageComponentsModule: + + object page: + def container( + children: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8 overflow-y-auto"), + children + ) + + def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement = + div( + cls("p-8 bg-gray-100 h-full"), + header, + children + ) + + def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + div( + cls("pb-5 border-b border-gray-200"), + div(cls("float-right"), right), + h1( + cls("text-2xl leading-6 font-medium text-gray-900"), + title + ), + subtitle.map( + p( + cls("text-sm font-medium text-gray-500"), + _ + ) + ) + ) + + def clickable: Modifier[HtmlElement] = + cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala new file mode 100644 index 0000000..3603d98 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait PanelComponentsModule: + object panel: + def basicCard(content: HtmlMod*) = + div( + cls("bg-white overflow-hidden shadow rounded-lg"), + div(cls("px-4 py-5 sm:p-6"), content) + ) + + /** Card, edge-to-edge on mobile */ + def cardEdgeToEdgeOnMobile(content: HtmlMod*) = + div( + cls("bg-white overflow-hidden shadow sm:rounded-lg"), + div(cls("px-4 py-5 sm:p-6"), content) + ) + + def cardWithHeader(header: HtmlMod*)(content: HtmlMod*) = + div( + cls( + "divide-y divide-gray-200 overflow-hidden rounded-lg bg-white shadow" + ), + div( + cls("px-4 py-5 sm:px-6"), + header + ), + div( + cls("px-4 py-5 sm:p-6"), + content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala new file mode 100644 index 0000000..d2bd069 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala @@ -0,0 +1,160 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind +import works.iterative.ui.components.laminar.tailwind.color.ColorWeight + +trait AlertComponentsModule: + self: IconsModule => + object alerts: + // TODO: use context functions and builder pattern to build the alerts + def alert( + message: HtmlMod, + icon: SvgElement, + color: ColorKind, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + div( + cls := "rounded-md p-4", + color(50).bg, + div( + cls := "flex", + icon, + div( + cls := "ml-3", + title.map(t => + h3( + cls := "text-sm font-medium", + color(800).text, + t + ) + ), + div( + cls := "text-sm", + title.map(_ => cls("mt-2")), + color(700).text, + message + ), + actions.map { act => + div( + cls := "mt-4", + act(color) + ) + } + ), + mods(color) + ) + ) + + def warning( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-warning`(), + ColorKind.yellow, + title = title, + actions = actions, + mods = mods + ) + + def error( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-error`(), + ColorKind.red, + title = title, + actions = actions, + mods = mods + ) + + def success( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-success`(), + ColorKind.green, + title = title, + actions = actions, + mods = mods + ) + + def info( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-info`(), + ColorKind.blue, + title = title, + actions = actions, + mods = mods + ) + + def buttons(buttons: (ColorKind => HtmlMod)*): ColorKind => HtmlMod = + color => + div( + cls("-mx-2 -my-1.5 flex"), + buttons.map(_(color)) + ) + + def button(title: String)(mods: HtmlMod*): ColorKind => HtmlMod = color => + L.button( + tpe("button"), + cls( + "first:ml-0 ml-3 rounded-md px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(800).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + title, + mods + ) + + def closeMod(mods: HtmlMod): ColorKind => HtmlMod = color => + val closeIcon: SvgElement = + import svg.* + svg( + cls := "h-5 w-5", + viewBox := "0 0 20 20", + fill := "currentColor", + path( + d := "M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" + ) + ) + div( + cls("ml-auto pl-3"), + L.button( + cls( + "-mx-1.5 -my-1.5 inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(500).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + mods, + aria.hidden := true, + closeIcon + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala new file mode 100644 index 0000000..fa374fe --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala @@ -0,0 +1,52 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait AppShellComponentsModule: + object shell: + object stackedLight extends StackedAppShell: + override def navCls = cls("border-b border-gray-200 bg-white") + override def headerCls = emptyMod + override def pageWrapper(content: HtmlMod*): HtmlMod = + div(cls("py-10"), content) + + object stackedBranded extends StackedAppShell: + override def navCls = cls("bg-indigo-600") + override def headerCls = cls("bg-white shadown-sm") + override def pageWrapper(content: HtmlMod*): HtmlMod = content + +trait StackedAppShell: + protected def navCls: HtmlMod + protected def headerCls: HtmlMod + protected def pageWrapper(content: HtmlMod*): HtmlMod + + def apply(pageTitle: HtmlMod)(navbarItems: HtmlMod*)( + content: HtmlMod* + ): HtmlElement = + div( + cls("min-h-full"), + navTag( + navCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + div(cls("flex h-16 items-center justify-between"), navbarItems) + ) + ), + pageWrapper( + headerTag( + headerCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + h1( + cls( + "text-3xl font-bold leading-tight tracking-tight text-gray-900" + ), + pageTitle + ) + ) + ), + mainTag( + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala new file mode 100644 index 0000000..f6d7d7f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait BadgeComponentsModule: + + object badges: + def pill(name: String, color: ColorKind): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + name + ) + + def pill(name: String, color: Signal[ColorKind]): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + cls <-- color.map(_.apply(800).text.toCSS), + cls <-- color.map(_.apply(100).bg.toCSS), + name + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala new file mode 100644 index 0000000..03a2c9c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait BreadcrumbsModule: + self: IconsModule => + + object breadcrumbs: + def container(mods: HtmlMod*): HtmlElement = + navTag(cls("flex"), aria.label("Breadcrumb"), mods) + + def list(mods: HtmlMod*): HtmlElement = + ol(cls("flex items-center space-x-4"), role("list"), mods) + + def homeItem(mods: HtmlMod*): HtmlElement = + li( + div( + a( + href("#"), + cls("text-gray-400 hover:text-gray-500"), + icons.home(svg.cls("h-5 w-5 flex-shrink-0")), + span(cls("sr-only"), "Home"), + mods + ) + ) + ) + + def item(mods: HtmlMod*): HtmlElement = + li( + div( + cls("flex items-center"), + icons.`chevron-right-solid`( + svg.cls("h-5 w-5 flex-shrink-0 text-gray-400") + ), + a( + href("#"), + cls("ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala new file mode 100644 index 0000000..27f3cf2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala @@ -0,0 +1,102 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +trait ButtonComponentsModule: + + object buttons: + + private inline def srHelp(text: String): Modifier[HtmlElement] = + span(cls := "sr-only", text) + + val sharedButtonClasses = + "inline-flex justify-center py-2 px-4 border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + + val sharedButtonMod: HtmlMod = cls(sharedButtonClasses) + + val primaryButtonClasses = + "border-transparent text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300" + val primaryButtonMod: HtmlMod = cls(primaryButtonClasses) + + val secondaryButtonClasses = + "border-gray-300 text-gray-700 hover:bg-gray-50" + val secondaryButtonMod: HtmlMod = cls(secondaryButtonClasses) + + val neutralButtonClasses = + "border-gray-300 text-gray-700 bg-white hover:bg-gray-50" + val neutralButtonMod: HtmlMod = cls(neutralButtonClasses) + + val positiveButtonClasses = + "border-transparent text-white bg-green-600 hover:bg-green-700" + val positiveButtonMod: HtmlMod = cls(positiveButtonClasses) + + val negativeButtonClasses = + "border-transparent text-white bg-red-600 hover:bg-red-700" + val negativeButtonMod: HtmlMod = cls(negativeButtonClasses) + + def button( + text: Modifier[HtmlElement], + id: Option[String], + icon: Option[SvgElement] = None, + buttonType: String = "submit", + primary: Boolean = false + )(mods: HtmlMod*): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe(buttonType), + sharedButtonMod, + if primary then primaryButtonMod else secondaryButtonMod, + icon, + text, + mods + ) + + def primaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "submit" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, true)(mods*) + + def secondaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "button" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, false)(mods*) + + def inlineButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srText.map(srHelp(_)), + icon + ) + + def iconButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srText.map(srHelp(_)), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala new file mode 100644 index 0000000..ae4ab86 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala @@ -0,0 +1,56 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +import io.laminext.syntax.core.* + +trait ComboboxModule: + self: IconsModule => + + object combobox: + object simple: + def container(mods: HtmlMod*): Div = div(cls("relative mt-2"), mods) + + def button(mods: HtmlMod*): Button = + L.button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + mods + ) + + def options(mods: HtmlMod*) = + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + role := "listbox", + mods + ) + + def option(isActive: Signal[Boolean], isSelected: Signal[Boolean])( + mods: HtmlMod* + ) = + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- isActive, + cls.toggle("text-gray-900") <-- isActive.not, + cls.toggle("font-semibold") <-- isSelected, + mods + ) + + def optionValue(l: String): HtmlElement = + span(cls("block truncate"), l) + + def checkmark(isActive: Signal[Boolean]): HtmlElement = + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- isActive.not, + cls.toggle("text-white") <-- isActive, + icons.check(svg.cls("h-5 w-5")) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala new file mode 100644 index 0000000..f5a8873 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ContainerComponentsModule: + object container: + /** Full-width on mobile, constrained with padded content above */ + def default(content: HtmlMod*): HtmlElement = + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + + def padded(content: HtmlMod*): HtmlElement = + default(cls("px-4"), content) + + def narrow(content: HtmlMod*): HtmlElement = + padded( + div(cls("mx-auto max-w-3xl"), content) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala new file mode 100644 index 0000000..d601ab6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala @@ -0,0 +1,117 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.core.FileRef +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.laminar.* +import works.iterative.core.UserMessage + +trait DetailComponentsModule: + self: IconsModule => + object details: + def sectionHeader( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls( + "flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls := "px-4 sm:px-0", + h3( + cls := "text-base font-semibold leading-7 text-gray-900", + title + ), + subtitle.map(st => + p( + cls := "mt-1 max-w-2xl text-sm leading-6 text-gray-500", + st + ) + ) + ), + div(cls("flex-shrink-0"), actions) + ) + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + )(content: HtmlMod): HtmlElement = + div(sectionHeader(title, subtitle, actions), content) + + def fields(items: Node*): HtmlElement = + div( + dl( + cls := "divide-y divide-gray-100", + items + ) + ) + + def field( + title: Node, + content: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "px-2 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0", + dt( + cls := "text-sm font-medium leading-6 text-gray-900", + title + ), + dd( + cls := "mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0", + content + ) + ) + + def files( + fs: Seq[FileRef], + fileMods: Option[(FileRef, Int) => HtmlMod] = None + )( + mods: HtmlMod* + )(using ComponentContext[?]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-100 rounded-md border border-gray-200", + fs.zipWithIndex + .map((f, i) => + fileMods match + case Some(fm) => file(f)(fm(f, i)) + case _ => file(f)() + ), + mods + ) + + def file(f: FileRef)(mods: HtmlMod*)(using + ComponentContext[?] + ): HtmlElement = + li( + cls := "flex items-center justify-between py-4 pl-4 pr-5 text-sm leading-6", + div( + cls := "flex w-0 flex-1 items-center", + icons.`paper-clip-solid`(), + div( + cls := "ml-4 flex min-w-0 flex-1 gap-2", + span( + cls := "truncate font-medium", + f.name + ), + f.sizeString.map(size => + span(cls := "flex-shrink-0 text-gray-400", size) + ) + ) + ), + div( + cls := "ml-4 flex-shrink-0", + a( + href := f.url, + target := "_blank", + cls := "font-medium text-indigo-600 hover:text-indigo-500", + UserMessage("file.download").asString + ) + ), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala new file mode 100644 index 0000000..e9d7f1b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala @@ -0,0 +1,202 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html +import io.laminext.syntax.core.* + +trait FormComponentsModule: + self: IconsModule => + + object forms: + + val inputClasses = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls("space-y-6 sm:space-y-5"), + div( + h3(cls("text-lg leading-6 font-medium text-gray-900"), title), + subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) + ), + div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) + ) + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: Modifier[HtmlElement]* + ): ReactiveHtmlElement[html.Label] = + L.label( + cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), + forId.map(id => L.forId(id)), + labelText, + if required then sup(cls("text-gray-400"), "* povinné pole") + else emptyMod, + mods + ) + + def field( + label: Modifier[HtmlElement] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls( + "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5" + ), + label, + div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) + ) + + def field( + id: String, + labelText: String, + input: HtmlElement, + help: Option[String] + ): HtmlElement = + field( + label(labelText, Some(id))() + )( + input.amend(idAttr(id)), + help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) + ) + + def form(mods: Modifier[HtmlElement]*)( + sections: Modifier[HtmlElement]* + )(actions: Modifier[HtmlElement]*): ReactiveHtmlElement[html.Form] = + L.form( + cls("space-y-8 divide-y divide-gray-200"), + mods, + sections, + div( + cls("pt-5"), + div(cls("flex justify-end"), actions) + ) + ) + + def inlineForm( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + def errorTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-red-600") + + def validationError(text: Modifier[HtmlElement]): HtmlElement = + p(errorTextMods, text) + + def helpTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-gray-500") + + def fieldHelp(text: Modifier[HtmlElement]): HtmlElement = + p(helpTextMods, text) + + def inputField( + id: String, + labelText: String, + placeholderText: Option[String] = None, + inputType: String = "text", + helpText: Option[String] = None + ): HtmlElement = + field( + id, + labelText, + input(id, inputType, placeholderText)(), + helpText + ) + + def input( + id: String, + inputType: String = "text", + placeholderText: Option[String] = None + )(mods: HtmlMod*): HtmlElement = + L.input( + cls(inputClasses), + idAttr(id), + nameAttr(id), + placeholderText.map(placeholder(_)), + tpe(inputType), + mods + ) + + def comboBoxSimple( + options: List[(String, String)], + selectedInitially: Option[String] = None, + id: Option[String] = None, + name: Option[String] = None + ): HtmlElement = + val expanded = Var(false) + val selected = Var(selectedInitially) + div( + cls("relative mt-2"), + L.input( + cls( + "w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + id.map(idAttr(_)), + name.map(nameAttr(_)), + tpe("text"), + role("combobox"), + aria.controls("options"), + aria.expanded <-- expanded + ), + button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + onClick.preventDefault --> (_ => expanded.toggle()) + ), + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + cls.toggle("hidden") <-- expanded.signal.not, + id.map(i => idAttr(s"${i}-options")), + role := "listbox", + // Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation. + // Active: "text-white bg-indigo-600", Not Active: "text-gray-900" + for (((v, l), i) <- options.zipWithIndex) + yield + val active = Var(false) + val isSelected = selected.signal.map(_.contains(v)) + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- active.signal, + cls.toggle("text-gray-900") <-- active.signal.not, + cls.toggle("font-semibold") <-- isSelected, + id.map(cid => idAttr(s"${cid}-option-${i}")), + role := "option", + tabIndex := -1, + // Selected: "font-semibold" + span(cls("block truncate"), l), + // Checkmark, only display for selected option. + // Active: "text-white", Not Active: "text-indigo-600" + isSelected.childWhenTrue( + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- active.signal.not, + cls.toggle("text-white") <-- active.signal, + icons.check(svg.cls("h-5 w-5")) + ) + ), + onClick.preventDefault.mapTo( + v + ) --> selected.writer.contramapSome, + onMouseEnter --> (_ => active.set(true)), + onMouseLeave --> (_ => active.set(false)) + ) + // More items... + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala new file mode 100644 index 0000000..6a44cce --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait HeadingsComponentsModule: + object headings: + def section(title: Node, actions: HtmlElement*): HtmlElement = + div( + cls("border-b border-gray-200 pb-5"), + div( + cls("sm:flex sm:items-center sm:justify-between"), + h3(cls("text-base font-semibold leading-6 text-gray-900"), title), + div( + cls("mt-3 flex sm:ml-4 sm:mt-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) + + def sectionWithSubtitle( + title: Node, + subtitle: Node, + actions: HtmlElement* + ): HtmlElement = + div( + cls("border-b border-gray-200 bg-white px-4 py-5 sm:px-6"), + div( + cls( + "-ml-4 -mt-4 flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls("ml-4 mt-4"), + h3( + cls("text-base font-semibold leading-6 text-gray-900"), + title + ), + p( + cls("mt-1 text-sm text-gray-500"), + subtitle + ) + ), + div( + cls("ml-4 mt-4 flex-shrink-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala new file mode 100644 index 0000000..be16e48 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala @@ -0,0 +1,321 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.codecs +import works.iterative.ui.components.laminar.CustomAttrs + +trait IconsModule: + object icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + def upload(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 24 24"), + path(d := "M0 0h24v24H0z", fill("none")), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + + def home(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 20 20"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + clipRule := "evenodd", + fillRule := "evenodd", + d := "M9.293 2.293a1 1 0 011.414 0l7 7A1 1 0 0117 11h-1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-3a1 1 0 00-1-1H9a1 1 0 00-1 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-6H3a1 1 0 01-.707-1.707l7-7z" + ) + ) + + def `x-mark-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + def `arrow-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18") + ) + ) + + def `chevron-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M15.75 19.5L8.25 12l7.5-7.5") + ) + ) + + def `chevron-right-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + clipRule("evenodd"), + fillRule("evenodd"), + d( + "M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" + ) + ) + ) + + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) + + def `paper-clip-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + cls := "flex-shrink-0 text-gray-400", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M15.621 4.379a3 3 0 00-4.242 0l-7 7a3 3 0 004.241 4.243h.001l.497-.5a.75.75 0 011.064 1.057l-.498.501-.002.002a4.5 4.5 0 01-6.364-6.364l7-7a4.5 4.5 0 016.368 6.36l-3.455 3.553A2.625 2.625 0 119.52 9.52l3.45-3.451a.75.75 0 111.061 1.06l-3.45 3.451a1.125 1.125 0 001.587 1.595l3.454-3.553a3 3 0 000-4.242z", + clipRule := "evenodd" + ) + ) + + def `exclamation-circle-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-warning`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-yellow-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-error`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-red-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z", + clipRule := "evenodd" + ) + ) + + def `alert-success`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-green-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z", + clipRule := "evenodd" + ) + ) + + def `alert-info`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-blue-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z", + clipRule := "evenodd" + ) + ) + + def spinner(mods: SvgMod*): SvgElement = svg( + withDefault(mods, cls("h-4 w-4")), + svgAttr("role", codecs.StringAsIsCodec, None) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + def arrowPath(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + fill("none"), + stroke("currentColor"), + strokeWidth("1.5"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d( + "M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" + ) + ) + ) + + def chevronUpDown(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z", + clipRule := "evenodd" + ) + ) + + def check(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z", + clipRule := "evenodd" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala new file mode 100644 index 0000000..70c4a7c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* + +trait LayoutModule: + object layout: + def cardMod: HtmlMod = cls("bg-white shadow px-4 py-5 sm:rounded-lg sm:p-6") + def card(content: Modifier[HtmlElement]*): HtmlElement = + div(cardMod, content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala new file mode 100644 index 0000000..5b64ad7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala @@ -0,0 +1,88 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait ListComponentsModule: + self: BadgeComponentsModule => + + object list: + def label( + text: String, + color: ColorKind + ): HtmlElement = badges.pill(text, color) + + def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None, + contentMod: Modifier[HtmlElement] = emptyMod + ): LI = + li( + cls("group"), + div( + contentMod, + cls( + "relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" + ), + avatar.map(a => + div( + cls("flex-shrink-0"), + div( + cls( + "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" + ), + a + ) + ) + ), + div( + cls("flex-1 min-w-0"), + p( + cls("text-sm font-medium text-gray-900"), + title, + span(cls("float-right"), right) + ), + subtitle.map(st => + p( + cls("text-sm text-gray-500 truncate"), + st + ) + ) + ) + ) + ) + + def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = + ul( + cls("relative z-0 divide-y divide-gray-200"), + role("list"), + children + ) + + def listSection( + header: String, + list: HtmlElement + ): Div = + div( + cls("relative"), + div( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + list + ) + + def navigation(sections: Modifier[HtmlElement]): HtmlElement = + navTag( + cls("flex-1 min-h-0 overflow-y-auto"), + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala new file mode 100644 index 0000000..d4a2c56 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ListContainerComponentsModule: + object listContainer: + def simpleWithDividers(items: HtmlMod*) = + ul( + role("list"), + cls("divide-y divide-gray-200"), + items.map(li(cls("py-4"), _)) + ) + + def cardWithDividers(items: HtmlMod*) = + div( + cls("overflow-hidden rounded-md bg-white shadow"), + ul( + role := "list", + cls("divide-y divide-gray-200"), + items.map( + li( + cls("px-6 py-4"), + _ + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala new file mode 100644 index 0000000..684f0fb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait ModalComponentsModule: + object modal: + def modalDialog( + content: Signal[Option[HtmlElement]], + isOpen: Signal[Boolean], + close: Observer[Unit] + ): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + onClick.preventDefault.mapTo(()) --> close + ) + ) + + div( + cls.toggle("hidden") <-- isOpen.not.combineWithFn(content)( + _ || _.isEmpty + ), + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-visible rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + child.maybe <-- content + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala new file mode 100644 index 0000000..866b529 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala @@ -0,0 +1,48 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* + +trait PageComponentsModule: + + object page: + def container( + children: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8 overflow-y-auto"), + children + ) + + def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement = + div( + cls("p-8 bg-gray-100 h-full"), + header, + children + ) + + def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + div( + cls("pb-5 border-b border-gray-200"), + div(cls("float-right"), right), + h1( + cls("text-2xl leading-6 font-medium text-gray-900"), + title + ), + subtitle.map( + p( + cls("text-sm font-medium text-gray-500"), + _ + ) + ) + ) + + def clickable: Modifier[HtmlElement] = + cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala new file mode 100644 index 0000000..3603d98 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait PanelComponentsModule: + object panel: + def basicCard(content: HtmlMod*) = + div( + cls("bg-white overflow-hidden shadow rounded-lg"), + div(cls("px-4 py-5 sm:p-6"), content) + ) + + /** Card, edge-to-edge on mobile */ + def cardEdgeToEdgeOnMobile(content: HtmlMod*) = + div( + cls("bg-white overflow-hidden shadow sm:rounded-lg"), + div(cls("px-4 py-5 sm:p-6"), content) + ) + + def cardWithHeader(header: HtmlMod*)(content: HtmlMod*) = + div( + cls( + "divide-y divide-gray-200 overflow-hidden rounded-lg bg-white shadow" + ), + div( + cls("px-4 py-5 sm:px-6"), + header + ), + div( + cls("px-4 py-5 sm:p-6"), + content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TableComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TableComponentsModule.scala new file mode 100644 index 0000000..8f57c10 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TableComponentsModule.scala @@ -0,0 +1,95 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableComponentsModule: + + object tables: + + def tableSection( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None, + actions: Modifier[HtmlElement]* + )( + table: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("px-4 sm:px-6 lg:px-8"), + div( + cls("sm:flex sm:items-center"), + div( + cls("sm:flex-auto"), + h1(cls("text-base font-semibold leading-6 text-gray-900"), title), + subtitle.map(st => p(cls("mt-2 text-sm text-gray-700"), st)) + ), + div(cls("mt-4 sm:ml-16 sm:mt-0 sm:flex-none"), actions) + ), + table + ) + + def tableContainer(table: ReactiveHtmlElement[html.Table]): HtmlElement = + div( + cls("mt-8 flow-root"), + div( + cls("-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"), + div( + cls("inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"), + table + ) + ) + ) + + def simpleTable(header: ReactiveHtmlElement[html.TableRow]*)( + body: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] = + table( + cls("min-w-full divide-y divide-gray-300"), + thead(header), + tbody(cls("divide-y divide-gray-200"), body) + ) + + def headerRow( + mods: HtmlMod* + )( + cells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] = + tr( + cells.zipWithIndex.map((c, i) => + if i == 0 then c.amend(cls("py-3.5 pl-4 pr-3 sm:pl-0")) + else if i == cells.length - 1 then + c.amend(cls("py-3.5 pr-4 pl-3 sm:pr-0")) + else c.amend(cls("py-3.5 px-3")) + ) + ) + + def dataRow( + mods: HtmlMod* + )( + cells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] = + tr( + mods, + cells.zipWithIndex.map((c, i) => + if i == 0 then c.amend(cls("py-4 pl-4 pr-3 sm:pl-0")) + else if i == cells.length - 1 then + c.amend(cls("py-4 pr-4 pl-3 sm:pr-0")) + else c.amend(cls("py-4 px-3")) + ) + ) + + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] = + th( + cls("text-left text-sm font-semibold text-gray-900"), + content + ) + + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] = + td( + cls("text-sm text-gray-500"), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala new file mode 100644 index 0000000..d2bd069 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala @@ -0,0 +1,160 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind +import works.iterative.ui.components.laminar.tailwind.color.ColorWeight + +trait AlertComponentsModule: + self: IconsModule => + object alerts: + // TODO: use context functions and builder pattern to build the alerts + def alert( + message: HtmlMod, + icon: SvgElement, + color: ColorKind, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + div( + cls := "rounded-md p-4", + color(50).bg, + div( + cls := "flex", + icon, + div( + cls := "ml-3", + title.map(t => + h3( + cls := "text-sm font-medium", + color(800).text, + t + ) + ), + div( + cls := "text-sm", + title.map(_ => cls("mt-2")), + color(700).text, + message + ), + actions.map { act => + div( + cls := "mt-4", + act(color) + ) + } + ), + mods(color) + ) + ) + + def warning( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-warning`(), + ColorKind.yellow, + title = title, + actions = actions, + mods = mods + ) + + def error( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-error`(), + ColorKind.red, + title = title, + actions = actions, + mods = mods + ) + + def success( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-success`(), + ColorKind.green, + title = title, + actions = actions, + mods = mods + ) + + def info( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-info`(), + ColorKind.blue, + title = title, + actions = actions, + mods = mods + ) + + def buttons(buttons: (ColorKind => HtmlMod)*): ColorKind => HtmlMod = + color => + div( + cls("-mx-2 -my-1.5 flex"), + buttons.map(_(color)) + ) + + def button(title: String)(mods: HtmlMod*): ColorKind => HtmlMod = color => + L.button( + tpe("button"), + cls( + "first:ml-0 ml-3 rounded-md px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(800).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + title, + mods + ) + + def closeMod(mods: HtmlMod): ColorKind => HtmlMod = color => + val closeIcon: SvgElement = + import svg.* + svg( + cls := "h-5 w-5", + viewBox := "0 0 20 20", + fill := "currentColor", + path( + d := "M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" + ) + ) + div( + cls("ml-auto pl-3"), + L.button( + cls( + "-mx-1.5 -my-1.5 inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(500).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + mods, + aria.hidden := true, + closeIcon + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala new file mode 100644 index 0000000..fa374fe --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala @@ -0,0 +1,52 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait AppShellComponentsModule: + object shell: + object stackedLight extends StackedAppShell: + override def navCls = cls("border-b border-gray-200 bg-white") + override def headerCls = emptyMod + override def pageWrapper(content: HtmlMod*): HtmlMod = + div(cls("py-10"), content) + + object stackedBranded extends StackedAppShell: + override def navCls = cls("bg-indigo-600") + override def headerCls = cls("bg-white shadown-sm") + override def pageWrapper(content: HtmlMod*): HtmlMod = content + +trait StackedAppShell: + protected def navCls: HtmlMod + protected def headerCls: HtmlMod + protected def pageWrapper(content: HtmlMod*): HtmlMod + + def apply(pageTitle: HtmlMod)(navbarItems: HtmlMod*)( + content: HtmlMod* + ): HtmlElement = + div( + cls("min-h-full"), + navTag( + navCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + div(cls("flex h-16 items-center justify-between"), navbarItems) + ) + ), + pageWrapper( + headerTag( + headerCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + h1( + cls( + "text-3xl font-bold leading-tight tracking-tight text-gray-900" + ), + pageTitle + ) + ) + ), + mainTag( + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala new file mode 100644 index 0000000..f6d7d7f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait BadgeComponentsModule: + + object badges: + def pill(name: String, color: ColorKind): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + name + ) + + def pill(name: String, color: Signal[ColorKind]): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + cls <-- color.map(_.apply(800).text.toCSS), + cls <-- color.map(_.apply(100).bg.toCSS), + name + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala new file mode 100644 index 0000000..03a2c9c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait BreadcrumbsModule: + self: IconsModule => + + object breadcrumbs: + def container(mods: HtmlMod*): HtmlElement = + navTag(cls("flex"), aria.label("Breadcrumb"), mods) + + def list(mods: HtmlMod*): HtmlElement = + ol(cls("flex items-center space-x-4"), role("list"), mods) + + def homeItem(mods: HtmlMod*): HtmlElement = + li( + div( + a( + href("#"), + cls("text-gray-400 hover:text-gray-500"), + icons.home(svg.cls("h-5 w-5 flex-shrink-0")), + span(cls("sr-only"), "Home"), + mods + ) + ) + ) + + def item(mods: HtmlMod*): HtmlElement = + li( + div( + cls("flex items-center"), + icons.`chevron-right-solid`( + svg.cls("h-5 w-5 flex-shrink-0 text-gray-400") + ), + a( + href("#"), + cls("ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala new file mode 100644 index 0000000..27f3cf2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala @@ -0,0 +1,102 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +trait ButtonComponentsModule: + + object buttons: + + private inline def srHelp(text: String): Modifier[HtmlElement] = + span(cls := "sr-only", text) + + val sharedButtonClasses = + "inline-flex justify-center py-2 px-4 border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + + val sharedButtonMod: HtmlMod = cls(sharedButtonClasses) + + val primaryButtonClasses = + "border-transparent text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300" + val primaryButtonMod: HtmlMod = cls(primaryButtonClasses) + + val secondaryButtonClasses = + "border-gray-300 text-gray-700 hover:bg-gray-50" + val secondaryButtonMod: HtmlMod = cls(secondaryButtonClasses) + + val neutralButtonClasses = + "border-gray-300 text-gray-700 bg-white hover:bg-gray-50" + val neutralButtonMod: HtmlMod = cls(neutralButtonClasses) + + val positiveButtonClasses = + "border-transparent text-white bg-green-600 hover:bg-green-700" + val positiveButtonMod: HtmlMod = cls(positiveButtonClasses) + + val negativeButtonClasses = + "border-transparent text-white bg-red-600 hover:bg-red-700" + val negativeButtonMod: HtmlMod = cls(negativeButtonClasses) + + def button( + text: Modifier[HtmlElement], + id: Option[String], + icon: Option[SvgElement] = None, + buttonType: String = "submit", + primary: Boolean = false + )(mods: HtmlMod*): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe(buttonType), + sharedButtonMod, + if primary then primaryButtonMod else secondaryButtonMod, + icon, + text, + mods + ) + + def primaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "submit" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, true)(mods*) + + def secondaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "button" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, false)(mods*) + + def inlineButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srText.map(srHelp(_)), + icon + ) + + def iconButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srText.map(srHelp(_)), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala new file mode 100644 index 0000000..ae4ab86 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala @@ -0,0 +1,56 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +import io.laminext.syntax.core.* + +trait ComboboxModule: + self: IconsModule => + + object combobox: + object simple: + def container(mods: HtmlMod*): Div = div(cls("relative mt-2"), mods) + + def button(mods: HtmlMod*): Button = + L.button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + mods + ) + + def options(mods: HtmlMod*) = + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + role := "listbox", + mods + ) + + def option(isActive: Signal[Boolean], isSelected: Signal[Boolean])( + mods: HtmlMod* + ) = + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- isActive, + cls.toggle("text-gray-900") <-- isActive.not, + cls.toggle("font-semibold") <-- isSelected, + mods + ) + + def optionValue(l: String): HtmlElement = + span(cls("block truncate"), l) + + def checkmark(isActive: Signal[Boolean]): HtmlElement = + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- isActive.not, + cls.toggle("text-white") <-- isActive, + icons.check(svg.cls("h-5 w-5")) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala new file mode 100644 index 0000000..f5a8873 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ContainerComponentsModule: + object container: + /** Full-width on mobile, constrained with padded content above */ + def default(content: HtmlMod*): HtmlElement = + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + + def padded(content: HtmlMod*): HtmlElement = + default(cls("px-4"), content) + + def narrow(content: HtmlMod*): HtmlElement = + padded( + div(cls("mx-auto max-w-3xl"), content) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala new file mode 100644 index 0000000..d601ab6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala @@ -0,0 +1,117 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.core.FileRef +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.laminar.* +import works.iterative.core.UserMessage + +trait DetailComponentsModule: + self: IconsModule => + object details: + def sectionHeader( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls( + "flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls := "px-4 sm:px-0", + h3( + cls := "text-base font-semibold leading-7 text-gray-900", + title + ), + subtitle.map(st => + p( + cls := "mt-1 max-w-2xl text-sm leading-6 text-gray-500", + st + ) + ) + ), + div(cls("flex-shrink-0"), actions) + ) + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + )(content: HtmlMod): HtmlElement = + div(sectionHeader(title, subtitle, actions), content) + + def fields(items: Node*): HtmlElement = + div( + dl( + cls := "divide-y divide-gray-100", + items + ) + ) + + def field( + title: Node, + content: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "px-2 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0", + dt( + cls := "text-sm font-medium leading-6 text-gray-900", + title + ), + dd( + cls := "mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0", + content + ) + ) + + def files( + fs: Seq[FileRef], + fileMods: Option[(FileRef, Int) => HtmlMod] = None + )( + mods: HtmlMod* + )(using ComponentContext[?]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-100 rounded-md border border-gray-200", + fs.zipWithIndex + .map((f, i) => + fileMods match + case Some(fm) => file(f)(fm(f, i)) + case _ => file(f)() + ), + mods + ) + + def file(f: FileRef)(mods: HtmlMod*)(using + ComponentContext[?] + ): HtmlElement = + li( + cls := "flex items-center justify-between py-4 pl-4 pr-5 text-sm leading-6", + div( + cls := "flex w-0 flex-1 items-center", + icons.`paper-clip-solid`(), + div( + cls := "ml-4 flex min-w-0 flex-1 gap-2", + span( + cls := "truncate font-medium", + f.name + ), + f.sizeString.map(size => + span(cls := "flex-shrink-0 text-gray-400", size) + ) + ) + ), + div( + cls := "ml-4 flex-shrink-0", + a( + href := f.url, + target := "_blank", + cls := "font-medium text-indigo-600 hover:text-indigo-500", + UserMessage("file.download").asString + ) + ), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala new file mode 100644 index 0000000..e9d7f1b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala @@ -0,0 +1,202 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html +import io.laminext.syntax.core.* + +trait FormComponentsModule: + self: IconsModule => + + object forms: + + val inputClasses = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls("space-y-6 sm:space-y-5"), + div( + h3(cls("text-lg leading-6 font-medium text-gray-900"), title), + subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) + ), + div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) + ) + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: Modifier[HtmlElement]* + ): ReactiveHtmlElement[html.Label] = + L.label( + cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), + forId.map(id => L.forId(id)), + labelText, + if required then sup(cls("text-gray-400"), "* povinné pole") + else emptyMod, + mods + ) + + def field( + label: Modifier[HtmlElement] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls( + "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5" + ), + label, + div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) + ) + + def field( + id: String, + labelText: String, + input: HtmlElement, + help: Option[String] + ): HtmlElement = + field( + label(labelText, Some(id))() + )( + input.amend(idAttr(id)), + help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) + ) + + def form(mods: Modifier[HtmlElement]*)( + sections: Modifier[HtmlElement]* + )(actions: Modifier[HtmlElement]*): ReactiveHtmlElement[html.Form] = + L.form( + cls("space-y-8 divide-y divide-gray-200"), + mods, + sections, + div( + cls("pt-5"), + div(cls("flex justify-end"), actions) + ) + ) + + def inlineForm( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + def errorTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-red-600") + + def validationError(text: Modifier[HtmlElement]): HtmlElement = + p(errorTextMods, text) + + def helpTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-gray-500") + + def fieldHelp(text: Modifier[HtmlElement]): HtmlElement = + p(helpTextMods, text) + + def inputField( + id: String, + labelText: String, + placeholderText: Option[String] = None, + inputType: String = "text", + helpText: Option[String] = None + ): HtmlElement = + field( + id, + labelText, + input(id, inputType, placeholderText)(), + helpText + ) + + def input( + id: String, + inputType: String = "text", + placeholderText: Option[String] = None + )(mods: HtmlMod*): HtmlElement = + L.input( + cls(inputClasses), + idAttr(id), + nameAttr(id), + placeholderText.map(placeholder(_)), + tpe(inputType), + mods + ) + + def comboBoxSimple( + options: List[(String, String)], + selectedInitially: Option[String] = None, + id: Option[String] = None, + name: Option[String] = None + ): HtmlElement = + val expanded = Var(false) + val selected = Var(selectedInitially) + div( + cls("relative mt-2"), + L.input( + cls( + "w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + id.map(idAttr(_)), + name.map(nameAttr(_)), + tpe("text"), + role("combobox"), + aria.controls("options"), + aria.expanded <-- expanded + ), + button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + onClick.preventDefault --> (_ => expanded.toggle()) + ), + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + cls.toggle("hidden") <-- expanded.signal.not, + id.map(i => idAttr(s"${i}-options")), + role := "listbox", + // Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation. + // Active: "text-white bg-indigo-600", Not Active: "text-gray-900" + for (((v, l), i) <- options.zipWithIndex) + yield + val active = Var(false) + val isSelected = selected.signal.map(_.contains(v)) + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- active.signal, + cls.toggle("text-gray-900") <-- active.signal.not, + cls.toggle("font-semibold") <-- isSelected, + id.map(cid => idAttr(s"${cid}-option-${i}")), + role := "option", + tabIndex := -1, + // Selected: "font-semibold" + span(cls("block truncate"), l), + // Checkmark, only display for selected option. + // Active: "text-white", Not Active: "text-indigo-600" + isSelected.childWhenTrue( + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- active.signal.not, + cls.toggle("text-white") <-- active.signal, + icons.check(svg.cls("h-5 w-5")) + ) + ), + onClick.preventDefault.mapTo( + v + ) --> selected.writer.contramapSome, + onMouseEnter --> (_ => active.set(true)), + onMouseLeave --> (_ => active.set(false)) + ) + // More items... + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala new file mode 100644 index 0000000..6a44cce --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait HeadingsComponentsModule: + object headings: + def section(title: Node, actions: HtmlElement*): HtmlElement = + div( + cls("border-b border-gray-200 pb-5"), + div( + cls("sm:flex sm:items-center sm:justify-between"), + h3(cls("text-base font-semibold leading-6 text-gray-900"), title), + div( + cls("mt-3 flex sm:ml-4 sm:mt-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) + + def sectionWithSubtitle( + title: Node, + subtitle: Node, + actions: HtmlElement* + ): HtmlElement = + div( + cls("border-b border-gray-200 bg-white px-4 py-5 sm:px-6"), + div( + cls( + "-ml-4 -mt-4 flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls("ml-4 mt-4"), + h3( + cls("text-base font-semibold leading-6 text-gray-900"), + title + ), + p( + cls("mt-1 text-sm text-gray-500"), + subtitle + ) + ), + div( + cls("ml-4 mt-4 flex-shrink-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala new file mode 100644 index 0000000..be16e48 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala @@ -0,0 +1,321 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.codecs +import works.iterative.ui.components.laminar.CustomAttrs + +trait IconsModule: + object icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + def upload(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 24 24"), + path(d := "M0 0h24v24H0z", fill("none")), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + + def home(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 20 20"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + clipRule := "evenodd", + fillRule := "evenodd", + d := "M9.293 2.293a1 1 0 011.414 0l7 7A1 1 0 0117 11h-1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-3a1 1 0 00-1-1H9a1 1 0 00-1 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-6H3a1 1 0 01-.707-1.707l7-7z" + ) + ) + + def `x-mark-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + def `arrow-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18") + ) + ) + + def `chevron-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M15.75 19.5L8.25 12l7.5-7.5") + ) + ) + + def `chevron-right-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + clipRule("evenodd"), + fillRule("evenodd"), + d( + "M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" + ) + ) + ) + + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) + + def `paper-clip-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + cls := "flex-shrink-0 text-gray-400", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M15.621 4.379a3 3 0 00-4.242 0l-7 7a3 3 0 004.241 4.243h.001l.497-.5a.75.75 0 011.064 1.057l-.498.501-.002.002a4.5 4.5 0 01-6.364-6.364l7-7a4.5 4.5 0 016.368 6.36l-3.455 3.553A2.625 2.625 0 119.52 9.52l3.45-3.451a.75.75 0 111.061 1.06l-3.45 3.451a1.125 1.125 0 001.587 1.595l3.454-3.553a3 3 0 000-4.242z", + clipRule := "evenodd" + ) + ) + + def `exclamation-circle-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-warning`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-yellow-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-error`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-red-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z", + clipRule := "evenodd" + ) + ) + + def `alert-success`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-green-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z", + clipRule := "evenodd" + ) + ) + + def `alert-info`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-blue-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z", + clipRule := "evenodd" + ) + ) + + def spinner(mods: SvgMod*): SvgElement = svg( + withDefault(mods, cls("h-4 w-4")), + svgAttr("role", codecs.StringAsIsCodec, None) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + def arrowPath(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + fill("none"), + stroke("currentColor"), + strokeWidth("1.5"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d( + "M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" + ) + ) + ) + + def chevronUpDown(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z", + clipRule := "evenodd" + ) + ) + + def check(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z", + clipRule := "evenodd" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala new file mode 100644 index 0000000..70c4a7c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* + +trait LayoutModule: + object layout: + def cardMod: HtmlMod = cls("bg-white shadow px-4 py-5 sm:rounded-lg sm:p-6") + def card(content: Modifier[HtmlElement]*): HtmlElement = + div(cardMod, content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala new file mode 100644 index 0000000..5b64ad7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala @@ -0,0 +1,88 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait ListComponentsModule: + self: BadgeComponentsModule => + + object list: + def label( + text: String, + color: ColorKind + ): HtmlElement = badges.pill(text, color) + + def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None, + contentMod: Modifier[HtmlElement] = emptyMod + ): LI = + li( + cls("group"), + div( + contentMod, + cls( + "relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" + ), + avatar.map(a => + div( + cls("flex-shrink-0"), + div( + cls( + "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" + ), + a + ) + ) + ), + div( + cls("flex-1 min-w-0"), + p( + cls("text-sm font-medium text-gray-900"), + title, + span(cls("float-right"), right) + ), + subtitle.map(st => + p( + cls("text-sm text-gray-500 truncate"), + st + ) + ) + ) + ) + ) + + def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = + ul( + cls("relative z-0 divide-y divide-gray-200"), + role("list"), + children + ) + + def listSection( + header: String, + list: HtmlElement + ): Div = + div( + cls("relative"), + div( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + list + ) + + def navigation(sections: Modifier[HtmlElement]): HtmlElement = + navTag( + cls("flex-1 min-h-0 overflow-y-auto"), + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala new file mode 100644 index 0000000..d4a2c56 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ListContainerComponentsModule: + object listContainer: + def simpleWithDividers(items: HtmlMod*) = + ul( + role("list"), + cls("divide-y divide-gray-200"), + items.map(li(cls("py-4"), _)) + ) + + def cardWithDividers(items: HtmlMod*) = + div( + cls("overflow-hidden rounded-md bg-white shadow"), + ul( + role := "list", + cls("divide-y divide-gray-200"), + items.map( + li( + cls("px-6 py-4"), + _ + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala new file mode 100644 index 0000000..684f0fb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait ModalComponentsModule: + object modal: + def modalDialog( + content: Signal[Option[HtmlElement]], + isOpen: Signal[Boolean], + close: Observer[Unit] + ): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + onClick.preventDefault.mapTo(()) --> close + ) + ) + + div( + cls.toggle("hidden") <-- isOpen.not.combineWithFn(content)( + _ || _.isEmpty + ), + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-visible rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + child.maybe <-- content + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala new file mode 100644 index 0000000..866b529 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala @@ -0,0 +1,48 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* + +trait PageComponentsModule: + + object page: + def container( + children: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8 overflow-y-auto"), + children + ) + + def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement = + div( + cls("p-8 bg-gray-100 h-full"), + header, + children + ) + + def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + div( + cls("pb-5 border-b border-gray-200"), + div(cls("float-right"), right), + h1( + cls("text-2xl leading-6 font-medium text-gray-900"), + title + ), + subtitle.map( + p( + cls("text-sm font-medium text-gray-500"), + _ + ) + ) + ) + + def clickable: Modifier[HtmlElement] = + cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala new file mode 100644 index 0000000..3603d98 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait PanelComponentsModule: + object panel: + def basicCard(content: HtmlMod*) = + div( + cls("bg-white overflow-hidden shadow rounded-lg"), + div(cls("px-4 py-5 sm:p-6"), content) + ) + + /** Card, edge-to-edge on mobile */ + def cardEdgeToEdgeOnMobile(content: HtmlMod*) = + div( + cls("bg-white overflow-hidden shadow sm:rounded-lg"), + div(cls("px-4 py-5 sm:p-6"), content) + ) + + def cardWithHeader(header: HtmlMod*)(content: HtmlMod*) = + div( + cls( + "divide-y divide-gray-200 overflow-hidden rounded-lg bg-white shadow" + ), + div( + cls("px-4 py-5 sm:px-6"), + header + ), + div( + cls("px-4 py-5 sm:p-6"), + content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TableComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TableComponentsModule.scala new file mode 100644 index 0000000..8f57c10 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TableComponentsModule.scala @@ -0,0 +1,95 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableComponentsModule: + + object tables: + + def tableSection( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None, + actions: Modifier[HtmlElement]* + )( + table: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("px-4 sm:px-6 lg:px-8"), + div( + cls("sm:flex sm:items-center"), + div( + cls("sm:flex-auto"), + h1(cls("text-base font-semibold leading-6 text-gray-900"), title), + subtitle.map(st => p(cls("mt-2 text-sm text-gray-700"), st)) + ), + div(cls("mt-4 sm:ml-16 sm:mt-0 sm:flex-none"), actions) + ), + table + ) + + def tableContainer(table: ReactiveHtmlElement[html.Table]): HtmlElement = + div( + cls("mt-8 flow-root"), + div( + cls("-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"), + div( + cls("inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"), + table + ) + ) + ) + + def simpleTable(header: ReactiveHtmlElement[html.TableRow]*)( + body: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] = + table( + cls("min-w-full divide-y divide-gray-300"), + thead(header), + tbody(cls("divide-y divide-gray-200"), body) + ) + + def headerRow( + mods: HtmlMod* + )( + cells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] = + tr( + cells.zipWithIndex.map((c, i) => + if i == 0 then c.amend(cls("py-3.5 pl-4 pr-3 sm:pl-0")) + else if i == cells.length - 1 then + c.amend(cls("py-3.5 pr-4 pl-3 sm:pr-0")) + else c.amend(cls("py-3.5 px-3")) + ) + ) + + def dataRow( + mods: HtmlMod* + )( + cells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] = + tr( + mods, + cells.zipWithIndex.map((c, i) => + if i == 0 then c.amend(cls("py-4 pl-4 pr-3 sm:pl-0")) + else if i == cells.length - 1 then + c.amend(cls("py-4 pr-4 pl-3 sm:pr-0")) + else c.amend(cls("py-4 px-3")) + ) + ) + + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] = + th( + cls("text-left text-sm font-semibold text-gray-900"), + content + ) + + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] = + td( + cls("text-sm text-gray-500"), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUICatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUICatalogue.scala new file mode 100644 index 0000000..57ad709 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUICatalogue.scala @@ -0,0 +1,25 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +trait TailwindUICatalogueModule + extends BadgeComponentsModule + with ButtonComponentsModule + with HeadingsComponentsModule + with IconsModule + with FormComponentsModule + with LayoutModule + with ListComponentsModule + with ModalComponentsModule + with PageComponentsModule + with TableComponentsModule + with DetailComponentsModule + with ContainerComponentsModule + with PanelComponentsModule + with ListContainerComponentsModule + with AppShellComponentsModule + with UserMenuComponentsModule + with ZeroComponentsModule + with AlertComponentsModule + with ComboboxModule + with BreadcrumbsModule + +object TailwindUICatalogue extends TailwindUICatalogueModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala new file mode 100644 index 0000000..d2bd069 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala @@ -0,0 +1,160 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind +import works.iterative.ui.components.laminar.tailwind.color.ColorWeight + +trait AlertComponentsModule: + self: IconsModule => + object alerts: + // TODO: use context functions and builder pattern to build the alerts + def alert( + message: HtmlMod, + icon: SvgElement, + color: ColorKind, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + div( + cls := "rounded-md p-4", + color(50).bg, + div( + cls := "flex", + icon, + div( + cls := "ml-3", + title.map(t => + h3( + cls := "text-sm font-medium", + color(800).text, + t + ) + ), + div( + cls := "text-sm", + title.map(_ => cls("mt-2")), + color(700).text, + message + ), + actions.map { act => + div( + cls := "mt-4", + act(color) + ) + } + ), + mods(color) + ) + ) + + def warning( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-warning`(), + ColorKind.yellow, + title = title, + actions = actions, + mods = mods + ) + + def error( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-error`(), + ColorKind.red, + title = title, + actions = actions, + mods = mods + ) + + def success( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-success`(), + ColorKind.green, + title = title, + actions = actions, + mods = mods + ) + + def info( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-info`(), + ColorKind.blue, + title = title, + actions = actions, + mods = mods + ) + + def buttons(buttons: (ColorKind => HtmlMod)*): ColorKind => HtmlMod = + color => + div( + cls("-mx-2 -my-1.5 flex"), + buttons.map(_(color)) + ) + + def button(title: String)(mods: HtmlMod*): ColorKind => HtmlMod = color => + L.button( + tpe("button"), + cls( + "first:ml-0 ml-3 rounded-md px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(800).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + title, + mods + ) + + def closeMod(mods: HtmlMod): ColorKind => HtmlMod = color => + val closeIcon: SvgElement = + import svg.* + svg( + cls := "h-5 w-5", + viewBox := "0 0 20 20", + fill := "currentColor", + path( + d := "M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" + ) + ) + div( + cls("ml-auto pl-3"), + L.button( + cls( + "-mx-1.5 -my-1.5 inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(500).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + mods, + aria.hidden := true, + closeIcon + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala new file mode 100644 index 0000000..fa374fe --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala @@ -0,0 +1,52 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait AppShellComponentsModule: + object shell: + object stackedLight extends StackedAppShell: + override def navCls = cls("border-b border-gray-200 bg-white") + override def headerCls = emptyMod + override def pageWrapper(content: HtmlMod*): HtmlMod = + div(cls("py-10"), content) + + object stackedBranded extends StackedAppShell: + override def navCls = cls("bg-indigo-600") + override def headerCls = cls("bg-white shadown-sm") + override def pageWrapper(content: HtmlMod*): HtmlMod = content + +trait StackedAppShell: + protected def navCls: HtmlMod + protected def headerCls: HtmlMod + protected def pageWrapper(content: HtmlMod*): HtmlMod + + def apply(pageTitle: HtmlMod)(navbarItems: HtmlMod*)( + content: HtmlMod* + ): HtmlElement = + div( + cls("min-h-full"), + navTag( + navCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + div(cls("flex h-16 items-center justify-between"), navbarItems) + ) + ), + pageWrapper( + headerTag( + headerCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + h1( + cls( + "text-3xl font-bold leading-tight tracking-tight text-gray-900" + ), + pageTitle + ) + ) + ), + mainTag( + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala new file mode 100644 index 0000000..f6d7d7f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait BadgeComponentsModule: + + object badges: + def pill(name: String, color: ColorKind): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + name + ) + + def pill(name: String, color: Signal[ColorKind]): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + cls <-- color.map(_.apply(800).text.toCSS), + cls <-- color.map(_.apply(100).bg.toCSS), + name + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala new file mode 100644 index 0000000..03a2c9c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait BreadcrumbsModule: + self: IconsModule => + + object breadcrumbs: + def container(mods: HtmlMod*): HtmlElement = + navTag(cls("flex"), aria.label("Breadcrumb"), mods) + + def list(mods: HtmlMod*): HtmlElement = + ol(cls("flex items-center space-x-4"), role("list"), mods) + + def homeItem(mods: HtmlMod*): HtmlElement = + li( + div( + a( + href("#"), + cls("text-gray-400 hover:text-gray-500"), + icons.home(svg.cls("h-5 w-5 flex-shrink-0")), + span(cls("sr-only"), "Home"), + mods + ) + ) + ) + + def item(mods: HtmlMod*): HtmlElement = + li( + div( + cls("flex items-center"), + icons.`chevron-right-solid`( + svg.cls("h-5 w-5 flex-shrink-0 text-gray-400") + ), + a( + href("#"), + cls("ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala new file mode 100644 index 0000000..27f3cf2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala @@ -0,0 +1,102 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +trait ButtonComponentsModule: + + object buttons: + + private inline def srHelp(text: String): Modifier[HtmlElement] = + span(cls := "sr-only", text) + + val sharedButtonClasses = + "inline-flex justify-center py-2 px-4 border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + + val sharedButtonMod: HtmlMod = cls(sharedButtonClasses) + + val primaryButtonClasses = + "border-transparent text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300" + val primaryButtonMod: HtmlMod = cls(primaryButtonClasses) + + val secondaryButtonClasses = + "border-gray-300 text-gray-700 hover:bg-gray-50" + val secondaryButtonMod: HtmlMod = cls(secondaryButtonClasses) + + val neutralButtonClasses = + "border-gray-300 text-gray-700 bg-white hover:bg-gray-50" + val neutralButtonMod: HtmlMod = cls(neutralButtonClasses) + + val positiveButtonClasses = + "border-transparent text-white bg-green-600 hover:bg-green-700" + val positiveButtonMod: HtmlMod = cls(positiveButtonClasses) + + val negativeButtonClasses = + "border-transparent text-white bg-red-600 hover:bg-red-700" + val negativeButtonMod: HtmlMod = cls(negativeButtonClasses) + + def button( + text: Modifier[HtmlElement], + id: Option[String], + icon: Option[SvgElement] = None, + buttonType: String = "submit", + primary: Boolean = false + )(mods: HtmlMod*): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe(buttonType), + sharedButtonMod, + if primary then primaryButtonMod else secondaryButtonMod, + icon, + text, + mods + ) + + def primaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "submit" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, true)(mods*) + + def secondaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "button" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, false)(mods*) + + def inlineButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srText.map(srHelp(_)), + icon + ) + + def iconButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srText.map(srHelp(_)), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala new file mode 100644 index 0000000..ae4ab86 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala @@ -0,0 +1,56 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +import io.laminext.syntax.core.* + +trait ComboboxModule: + self: IconsModule => + + object combobox: + object simple: + def container(mods: HtmlMod*): Div = div(cls("relative mt-2"), mods) + + def button(mods: HtmlMod*): Button = + L.button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + mods + ) + + def options(mods: HtmlMod*) = + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + role := "listbox", + mods + ) + + def option(isActive: Signal[Boolean], isSelected: Signal[Boolean])( + mods: HtmlMod* + ) = + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- isActive, + cls.toggle("text-gray-900") <-- isActive.not, + cls.toggle("font-semibold") <-- isSelected, + mods + ) + + def optionValue(l: String): HtmlElement = + span(cls("block truncate"), l) + + def checkmark(isActive: Signal[Boolean]): HtmlElement = + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- isActive.not, + cls.toggle("text-white") <-- isActive, + icons.check(svg.cls("h-5 w-5")) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala new file mode 100644 index 0000000..f5a8873 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ContainerComponentsModule: + object container: + /** Full-width on mobile, constrained with padded content above */ + def default(content: HtmlMod*): HtmlElement = + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + + def padded(content: HtmlMod*): HtmlElement = + default(cls("px-4"), content) + + def narrow(content: HtmlMod*): HtmlElement = + padded( + div(cls("mx-auto max-w-3xl"), content) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala new file mode 100644 index 0000000..d601ab6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala @@ -0,0 +1,117 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.core.FileRef +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.laminar.* +import works.iterative.core.UserMessage + +trait DetailComponentsModule: + self: IconsModule => + object details: + def sectionHeader( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls( + "flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls := "px-4 sm:px-0", + h3( + cls := "text-base font-semibold leading-7 text-gray-900", + title + ), + subtitle.map(st => + p( + cls := "mt-1 max-w-2xl text-sm leading-6 text-gray-500", + st + ) + ) + ), + div(cls("flex-shrink-0"), actions) + ) + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + )(content: HtmlMod): HtmlElement = + div(sectionHeader(title, subtitle, actions), content) + + def fields(items: Node*): HtmlElement = + div( + dl( + cls := "divide-y divide-gray-100", + items + ) + ) + + def field( + title: Node, + content: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "px-2 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0", + dt( + cls := "text-sm font-medium leading-6 text-gray-900", + title + ), + dd( + cls := "mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0", + content + ) + ) + + def files( + fs: Seq[FileRef], + fileMods: Option[(FileRef, Int) => HtmlMod] = None + )( + mods: HtmlMod* + )(using ComponentContext[?]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-100 rounded-md border border-gray-200", + fs.zipWithIndex + .map((f, i) => + fileMods match + case Some(fm) => file(f)(fm(f, i)) + case _ => file(f)() + ), + mods + ) + + def file(f: FileRef)(mods: HtmlMod*)(using + ComponentContext[?] + ): HtmlElement = + li( + cls := "flex items-center justify-between py-4 pl-4 pr-5 text-sm leading-6", + div( + cls := "flex w-0 flex-1 items-center", + icons.`paper-clip-solid`(), + div( + cls := "ml-4 flex min-w-0 flex-1 gap-2", + span( + cls := "truncate font-medium", + f.name + ), + f.sizeString.map(size => + span(cls := "flex-shrink-0 text-gray-400", size) + ) + ) + ), + div( + cls := "ml-4 flex-shrink-0", + a( + href := f.url, + target := "_blank", + cls := "font-medium text-indigo-600 hover:text-indigo-500", + UserMessage("file.download").asString + ) + ), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala new file mode 100644 index 0000000..e9d7f1b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala @@ -0,0 +1,202 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html +import io.laminext.syntax.core.* + +trait FormComponentsModule: + self: IconsModule => + + object forms: + + val inputClasses = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls("space-y-6 sm:space-y-5"), + div( + h3(cls("text-lg leading-6 font-medium text-gray-900"), title), + subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) + ), + div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) + ) + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: Modifier[HtmlElement]* + ): ReactiveHtmlElement[html.Label] = + L.label( + cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), + forId.map(id => L.forId(id)), + labelText, + if required then sup(cls("text-gray-400"), "* povinné pole") + else emptyMod, + mods + ) + + def field( + label: Modifier[HtmlElement] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls( + "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5" + ), + label, + div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) + ) + + def field( + id: String, + labelText: String, + input: HtmlElement, + help: Option[String] + ): HtmlElement = + field( + label(labelText, Some(id))() + )( + input.amend(idAttr(id)), + help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) + ) + + def form(mods: Modifier[HtmlElement]*)( + sections: Modifier[HtmlElement]* + )(actions: Modifier[HtmlElement]*): ReactiveHtmlElement[html.Form] = + L.form( + cls("space-y-8 divide-y divide-gray-200"), + mods, + sections, + div( + cls("pt-5"), + div(cls("flex justify-end"), actions) + ) + ) + + def inlineForm( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + def errorTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-red-600") + + def validationError(text: Modifier[HtmlElement]): HtmlElement = + p(errorTextMods, text) + + def helpTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-gray-500") + + def fieldHelp(text: Modifier[HtmlElement]): HtmlElement = + p(helpTextMods, text) + + def inputField( + id: String, + labelText: String, + placeholderText: Option[String] = None, + inputType: String = "text", + helpText: Option[String] = None + ): HtmlElement = + field( + id, + labelText, + input(id, inputType, placeholderText)(), + helpText + ) + + def input( + id: String, + inputType: String = "text", + placeholderText: Option[String] = None + )(mods: HtmlMod*): HtmlElement = + L.input( + cls(inputClasses), + idAttr(id), + nameAttr(id), + placeholderText.map(placeholder(_)), + tpe(inputType), + mods + ) + + def comboBoxSimple( + options: List[(String, String)], + selectedInitially: Option[String] = None, + id: Option[String] = None, + name: Option[String] = None + ): HtmlElement = + val expanded = Var(false) + val selected = Var(selectedInitially) + div( + cls("relative mt-2"), + L.input( + cls( + "w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + id.map(idAttr(_)), + name.map(nameAttr(_)), + tpe("text"), + role("combobox"), + aria.controls("options"), + aria.expanded <-- expanded + ), + button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + onClick.preventDefault --> (_ => expanded.toggle()) + ), + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + cls.toggle("hidden") <-- expanded.signal.not, + id.map(i => idAttr(s"${i}-options")), + role := "listbox", + // Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation. + // Active: "text-white bg-indigo-600", Not Active: "text-gray-900" + for (((v, l), i) <- options.zipWithIndex) + yield + val active = Var(false) + val isSelected = selected.signal.map(_.contains(v)) + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- active.signal, + cls.toggle("text-gray-900") <-- active.signal.not, + cls.toggle("font-semibold") <-- isSelected, + id.map(cid => idAttr(s"${cid}-option-${i}")), + role := "option", + tabIndex := -1, + // Selected: "font-semibold" + span(cls("block truncate"), l), + // Checkmark, only display for selected option. + // Active: "text-white", Not Active: "text-indigo-600" + isSelected.childWhenTrue( + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- active.signal.not, + cls.toggle("text-white") <-- active.signal, + icons.check(svg.cls("h-5 w-5")) + ) + ), + onClick.preventDefault.mapTo( + v + ) --> selected.writer.contramapSome, + onMouseEnter --> (_ => active.set(true)), + onMouseLeave --> (_ => active.set(false)) + ) + // More items... + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala new file mode 100644 index 0000000..6a44cce --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait HeadingsComponentsModule: + object headings: + def section(title: Node, actions: HtmlElement*): HtmlElement = + div( + cls("border-b border-gray-200 pb-5"), + div( + cls("sm:flex sm:items-center sm:justify-between"), + h3(cls("text-base font-semibold leading-6 text-gray-900"), title), + div( + cls("mt-3 flex sm:ml-4 sm:mt-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) + + def sectionWithSubtitle( + title: Node, + subtitle: Node, + actions: HtmlElement* + ): HtmlElement = + div( + cls("border-b border-gray-200 bg-white px-4 py-5 sm:px-6"), + div( + cls( + "-ml-4 -mt-4 flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls("ml-4 mt-4"), + h3( + cls("text-base font-semibold leading-6 text-gray-900"), + title + ), + p( + cls("mt-1 text-sm text-gray-500"), + subtitle + ) + ), + div( + cls("ml-4 mt-4 flex-shrink-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala new file mode 100644 index 0000000..be16e48 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala @@ -0,0 +1,321 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.codecs +import works.iterative.ui.components.laminar.CustomAttrs + +trait IconsModule: + object icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + def upload(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 24 24"), + path(d := "M0 0h24v24H0z", fill("none")), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + + def home(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 20 20"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + clipRule := "evenodd", + fillRule := "evenodd", + d := "M9.293 2.293a1 1 0 011.414 0l7 7A1 1 0 0117 11h-1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-3a1 1 0 00-1-1H9a1 1 0 00-1 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-6H3a1 1 0 01-.707-1.707l7-7z" + ) + ) + + def `x-mark-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + def `arrow-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18") + ) + ) + + def `chevron-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M15.75 19.5L8.25 12l7.5-7.5") + ) + ) + + def `chevron-right-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + clipRule("evenodd"), + fillRule("evenodd"), + d( + "M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" + ) + ) + ) + + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) + + def `paper-clip-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + cls := "flex-shrink-0 text-gray-400", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M15.621 4.379a3 3 0 00-4.242 0l-7 7a3 3 0 004.241 4.243h.001l.497-.5a.75.75 0 011.064 1.057l-.498.501-.002.002a4.5 4.5 0 01-6.364-6.364l7-7a4.5 4.5 0 016.368 6.36l-3.455 3.553A2.625 2.625 0 119.52 9.52l3.45-3.451a.75.75 0 111.061 1.06l-3.45 3.451a1.125 1.125 0 001.587 1.595l3.454-3.553a3 3 0 000-4.242z", + clipRule := "evenodd" + ) + ) + + def `exclamation-circle-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-warning`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-yellow-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-error`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-red-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z", + clipRule := "evenodd" + ) + ) + + def `alert-success`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-green-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z", + clipRule := "evenodd" + ) + ) + + def `alert-info`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-blue-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z", + clipRule := "evenodd" + ) + ) + + def spinner(mods: SvgMod*): SvgElement = svg( + withDefault(mods, cls("h-4 w-4")), + svgAttr("role", codecs.StringAsIsCodec, None) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + def arrowPath(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + fill("none"), + stroke("currentColor"), + strokeWidth("1.5"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d( + "M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" + ) + ) + ) + + def chevronUpDown(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z", + clipRule := "evenodd" + ) + ) + + def check(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z", + clipRule := "evenodd" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala new file mode 100644 index 0000000..70c4a7c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* + +trait LayoutModule: + object layout: + def cardMod: HtmlMod = cls("bg-white shadow px-4 py-5 sm:rounded-lg sm:p-6") + def card(content: Modifier[HtmlElement]*): HtmlElement = + div(cardMod, content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala new file mode 100644 index 0000000..5b64ad7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala @@ -0,0 +1,88 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait ListComponentsModule: + self: BadgeComponentsModule => + + object list: + def label( + text: String, + color: ColorKind + ): HtmlElement = badges.pill(text, color) + + def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None, + contentMod: Modifier[HtmlElement] = emptyMod + ): LI = + li( + cls("group"), + div( + contentMod, + cls( + "relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" + ), + avatar.map(a => + div( + cls("flex-shrink-0"), + div( + cls( + "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" + ), + a + ) + ) + ), + div( + cls("flex-1 min-w-0"), + p( + cls("text-sm font-medium text-gray-900"), + title, + span(cls("float-right"), right) + ), + subtitle.map(st => + p( + cls("text-sm text-gray-500 truncate"), + st + ) + ) + ) + ) + ) + + def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = + ul( + cls("relative z-0 divide-y divide-gray-200"), + role("list"), + children + ) + + def listSection( + header: String, + list: HtmlElement + ): Div = + div( + cls("relative"), + div( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + list + ) + + def navigation(sections: Modifier[HtmlElement]): HtmlElement = + navTag( + cls("flex-1 min-h-0 overflow-y-auto"), + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala new file mode 100644 index 0000000..d4a2c56 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ListContainerComponentsModule: + object listContainer: + def simpleWithDividers(items: HtmlMod*) = + ul( + role("list"), + cls("divide-y divide-gray-200"), + items.map(li(cls("py-4"), _)) + ) + + def cardWithDividers(items: HtmlMod*) = + div( + cls("overflow-hidden rounded-md bg-white shadow"), + ul( + role := "list", + cls("divide-y divide-gray-200"), + items.map( + li( + cls("px-6 py-4"), + _ + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala new file mode 100644 index 0000000..684f0fb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait ModalComponentsModule: + object modal: + def modalDialog( + content: Signal[Option[HtmlElement]], + isOpen: Signal[Boolean], + close: Observer[Unit] + ): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + onClick.preventDefault.mapTo(()) --> close + ) + ) + + div( + cls.toggle("hidden") <-- isOpen.not.combineWithFn(content)( + _ || _.isEmpty + ), + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-visible rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + child.maybe <-- content + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala new file mode 100644 index 0000000..866b529 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala @@ -0,0 +1,48 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* + +trait PageComponentsModule: + + object page: + def container( + children: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8 overflow-y-auto"), + children + ) + + def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement = + div( + cls("p-8 bg-gray-100 h-full"), + header, + children + ) + + def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + div( + cls("pb-5 border-b border-gray-200"), + div(cls("float-right"), right), + h1( + cls("text-2xl leading-6 font-medium text-gray-900"), + title + ), + subtitle.map( + p( + cls("text-sm font-medium text-gray-500"), + _ + ) + ) + ) + + def clickable: Modifier[HtmlElement] = + cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala new file mode 100644 index 0000000..3603d98 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait PanelComponentsModule: + object panel: + def basicCard(content: HtmlMod*) = + div( + cls("bg-white overflow-hidden shadow rounded-lg"), + div(cls("px-4 py-5 sm:p-6"), content) + ) + + /** Card, edge-to-edge on mobile */ + def cardEdgeToEdgeOnMobile(content: HtmlMod*) = + div( + cls("bg-white overflow-hidden shadow sm:rounded-lg"), + div(cls("px-4 py-5 sm:p-6"), content) + ) + + def cardWithHeader(header: HtmlMod*)(content: HtmlMod*) = + div( + cls( + "divide-y divide-gray-200 overflow-hidden rounded-lg bg-white shadow" + ), + div( + cls("px-4 py-5 sm:px-6"), + header + ), + div( + cls("px-4 py-5 sm:p-6"), + content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TableComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TableComponentsModule.scala new file mode 100644 index 0000000..8f57c10 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TableComponentsModule.scala @@ -0,0 +1,95 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableComponentsModule: + + object tables: + + def tableSection( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None, + actions: Modifier[HtmlElement]* + )( + table: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("px-4 sm:px-6 lg:px-8"), + div( + cls("sm:flex sm:items-center"), + div( + cls("sm:flex-auto"), + h1(cls("text-base font-semibold leading-6 text-gray-900"), title), + subtitle.map(st => p(cls("mt-2 text-sm text-gray-700"), st)) + ), + div(cls("mt-4 sm:ml-16 sm:mt-0 sm:flex-none"), actions) + ), + table + ) + + def tableContainer(table: ReactiveHtmlElement[html.Table]): HtmlElement = + div( + cls("mt-8 flow-root"), + div( + cls("-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"), + div( + cls("inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"), + table + ) + ) + ) + + def simpleTable(header: ReactiveHtmlElement[html.TableRow]*)( + body: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] = + table( + cls("min-w-full divide-y divide-gray-300"), + thead(header), + tbody(cls("divide-y divide-gray-200"), body) + ) + + def headerRow( + mods: HtmlMod* + )( + cells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] = + tr( + cells.zipWithIndex.map((c, i) => + if i == 0 then c.amend(cls("py-3.5 pl-4 pr-3 sm:pl-0")) + else if i == cells.length - 1 then + c.amend(cls("py-3.5 pr-4 pl-3 sm:pr-0")) + else c.amend(cls("py-3.5 px-3")) + ) + ) + + def dataRow( + mods: HtmlMod* + )( + cells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] = + tr( + mods, + cells.zipWithIndex.map((c, i) => + if i == 0 then c.amend(cls("py-4 pl-4 pr-3 sm:pl-0")) + else if i == cells.length - 1 then + c.amend(cls("py-4 pr-4 pl-3 sm:pr-0")) + else c.amend(cls("py-4 px-3")) + ) + ) + + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] = + th( + cls("text-left text-sm font-semibold text-gray-900"), + content + ) + + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] = + td( + cls("text-sm text-gray-500"), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUICatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUICatalogue.scala new file mode 100644 index 0000000..57ad709 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUICatalogue.scala @@ -0,0 +1,25 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +trait TailwindUICatalogueModule + extends BadgeComponentsModule + with ButtonComponentsModule + with HeadingsComponentsModule + with IconsModule + with FormComponentsModule + with LayoutModule + with ListComponentsModule + with ModalComponentsModule + with PageComponentsModule + with TableComponentsModule + with DetailComponentsModule + with ContainerComponentsModule + with PanelComponentsModule + with ListContainerComponentsModule + with AppShellComponentsModule + with UserMenuComponentsModule + with ZeroComponentsModule + with AlertComponentsModule + with ComboboxModule + with BreadcrumbsModule + +object TailwindUICatalogue extends TailwindUICatalogueModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormBuilderModule.scala new file mode 100644 index 0000000..81eb351 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormBuilderModule.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormMessagesResolver +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.forms.FormUIFactory +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait TailwindUIFormBuilderModule extends FormBuilderModule + +object TailwindUIFormBuilderModule: + given (using ctx: ComponentContext[_]): FormBuilderContext with + override def formMessagesResolver: FormMessagesResolver = + summon[FormMessagesResolver] + override def formUIFactory: FormUIFactory = TailwindUIFormUIFactory diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala new file mode 100644 index 0000000..d2bd069 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala @@ -0,0 +1,160 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind +import works.iterative.ui.components.laminar.tailwind.color.ColorWeight + +trait AlertComponentsModule: + self: IconsModule => + object alerts: + // TODO: use context functions and builder pattern to build the alerts + def alert( + message: HtmlMod, + icon: SvgElement, + color: ColorKind, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + div( + cls := "rounded-md p-4", + color(50).bg, + div( + cls := "flex", + icon, + div( + cls := "ml-3", + title.map(t => + h3( + cls := "text-sm font-medium", + color(800).text, + t + ) + ), + div( + cls := "text-sm", + title.map(_ => cls("mt-2")), + color(700).text, + message + ), + actions.map { act => + div( + cls := "mt-4", + act(color) + ) + } + ), + mods(color) + ) + ) + + def warning( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-warning`(), + ColorKind.yellow, + title = title, + actions = actions, + mods = mods + ) + + def error( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-error`(), + ColorKind.red, + title = title, + actions = actions, + mods = mods + ) + + def success( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-success`(), + ColorKind.green, + title = title, + actions = actions, + mods = mods + ) + + def info( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-info`(), + ColorKind.blue, + title = title, + actions = actions, + mods = mods + ) + + def buttons(buttons: (ColorKind => HtmlMod)*): ColorKind => HtmlMod = + color => + div( + cls("-mx-2 -my-1.5 flex"), + buttons.map(_(color)) + ) + + def button(title: String)(mods: HtmlMod*): ColorKind => HtmlMod = color => + L.button( + tpe("button"), + cls( + "first:ml-0 ml-3 rounded-md px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(800).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + title, + mods + ) + + def closeMod(mods: HtmlMod): ColorKind => HtmlMod = color => + val closeIcon: SvgElement = + import svg.* + svg( + cls := "h-5 w-5", + viewBox := "0 0 20 20", + fill := "currentColor", + path( + d := "M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" + ) + ) + div( + cls("ml-auto pl-3"), + L.button( + cls( + "-mx-1.5 -my-1.5 inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(500).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + mods, + aria.hidden := true, + closeIcon + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala new file mode 100644 index 0000000..fa374fe --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala @@ -0,0 +1,52 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait AppShellComponentsModule: + object shell: + object stackedLight extends StackedAppShell: + override def navCls = cls("border-b border-gray-200 bg-white") + override def headerCls = emptyMod + override def pageWrapper(content: HtmlMod*): HtmlMod = + div(cls("py-10"), content) + + object stackedBranded extends StackedAppShell: + override def navCls = cls("bg-indigo-600") + override def headerCls = cls("bg-white shadown-sm") + override def pageWrapper(content: HtmlMod*): HtmlMod = content + +trait StackedAppShell: + protected def navCls: HtmlMod + protected def headerCls: HtmlMod + protected def pageWrapper(content: HtmlMod*): HtmlMod + + def apply(pageTitle: HtmlMod)(navbarItems: HtmlMod*)( + content: HtmlMod* + ): HtmlElement = + div( + cls("min-h-full"), + navTag( + navCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + div(cls("flex h-16 items-center justify-between"), navbarItems) + ) + ), + pageWrapper( + headerTag( + headerCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + h1( + cls( + "text-3xl font-bold leading-tight tracking-tight text-gray-900" + ), + pageTitle + ) + ) + ), + mainTag( + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala new file mode 100644 index 0000000..f6d7d7f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait BadgeComponentsModule: + + object badges: + def pill(name: String, color: ColorKind): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + name + ) + + def pill(name: String, color: Signal[ColorKind]): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + cls <-- color.map(_.apply(800).text.toCSS), + cls <-- color.map(_.apply(100).bg.toCSS), + name + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala new file mode 100644 index 0000000..03a2c9c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait BreadcrumbsModule: + self: IconsModule => + + object breadcrumbs: + def container(mods: HtmlMod*): HtmlElement = + navTag(cls("flex"), aria.label("Breadcrumb"), mods) + + def list(mods: HtmlMod*): HtmlElement = + ol(cls("flex items-center space-x-4"), role("list"), mods) + + def homeItem(mods: HtmlMod*): HtmlElement = + li( + div( + a( + href("#"), + cls("text-gray-400 hover:text-gray-500"), + icons.home(svg.cls("h-5 w-5 flex-shrink-0")), + span(cls("sr-only"), "Home"), + mods + ) + ) + ) + + def item(mods: HtmlMod*): HtmlElement = + li( + div( + cls("flex items-center"), + icons.`chevron-right-solid`( + svg.cls("h-5 w-5 flex-shrink-0 text-gray-400") + ), + a( + href("#"), + cls("ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala new file mode 100644 index 0000000..27f3cf2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala @@ -0,0 +1,102 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +trait ButtonComponentsModule: + + object buttons: + + private inline def srHelp(text: String): Modifier[HtmlElement] = + span(cls := "sr-only", text) + + val sharedButtonClasses = + "inline-flex justify-center py-2 px-4 border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + + val sharedButtonMod: HtmlMod = cls(sharedButtonClasses) + + val primaryButtonClasses = + "border-transparent text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300" + val primaryButtonMod: HtmlMod = cls(primaryButtonClasses) + + val secondaryButtonClasses = + "border-gray-300 text-gray-700 hover:bg-gray-50" + val secondaryButtonMod: HtmlMod = cls(secondaryButtonClasses) + + val neutralButtonClasses = + "border-gray-300 text-gray-700 bg-white hover:bg-gray-50" + val neutralButtonMod: HtmlMod = cls(neutralButtonClasses) + + val positiveButtonClasses = + "border-transparent text-white bg-green-600 hover:bg-green-700" + val positiveButtonMod: HtmlMod = cls(positiveButtonClasses) + + val negativeButtonClasses = + "border-transparent text-white bg-red-600 hover:bg-red-700" + val negativeButtonMod: HtmlMod = cls(negativeButtonClasses) + + def button( + text: Modifier[HtmlElement], + id: Option[String], + icon: Option[SvgElement] = None, + buttonType: String = "submit", + primary: Boolean = false + )(mods: HtmlMod*): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe(buttonType), + sharedButtonMod, + if primary then primaryButtonMod else secondaryButtonMod, + icon, + text, + mods + ) + + def primaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "submit" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, true)(mods*) + + def secondaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "button" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, false)(mods*) + + def inlineButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srText.map(srHelp(_)), + icon + ) + + def iconButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srText.map(srHelp(_)), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala new file mode 100644 index 0000000..ae4ab86 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala @@ -0,0 +1,56 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +import io.laminext.syntax.core.* + +trait ComboboxModule: + self: IconsModule => + + object combobox: + object simple: + def container(mods: HtmlMod*): Div = div(cls("relative mt-2"), mods) + + def button(mods: HtmlMod*): Button = + L.button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + mods + ) + + def options(mods: HtmlMod*) = + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + role := "listbox", + mods + ) + + def option(isActive: Signal[Boolean], isSelected: Signal[Boolean])( + mods: HtmlMod* + ) = + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- isActive, + cls.toggle("text-gray-900") <-- isActive.not, + cls.toggle("font-semibold") <-- isSelected, + mods + ) + + def optionValue(l: String): HtmlElement = + span(cls("block truncate"), l) + + def checkmark(isActive: Signal[Boolean]): HtmlElement = + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- isActive.not, + cls.toggle("text-white") <-- isActive, + icons.check(svg.cls("h-5 w-5")) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala new file mode 100644 index 0000000..f5a8873 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ContainerComponentsModule: + object container: + /** Full-width on mobile, constrained with padded content above */ + def default(content: HtmlMod*): HtmlElement = + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + + def padded(content: HtmlMod*): HtmlElement = + default(cls("px-4"), content) + + def narrow(content: HtmlMod*): HtmlElement = + padded( + div(cls("mx-auto max-w-3xl"), content) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala new file mode 100644 index 0000000..d601ab6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala @@ -0,0 +1,117 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.core.FileRef +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.laminar.* +import works.iterative.core.UserMessage + +trait DetailComponentsModule: + self: IconsModule => + object details: + def sectionHeader( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls( + "flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls := "px-4 sm:px-0", + h3( + cls := "text-base font-semibold leading-7 text-gray-900", + title + ), + subtitle.map(st => + p( + cls := "mt-1 max-w-2xl text-sm leading-6 text-gray-500", + st + ) + ) + ), + div(cls("flex-shrink-0"), actions) + ) + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + )(content: HtmlMod): HtmlElement = + div(sectionHeader(title, subtitle, actions), content) + + def fields(items: Node*): HtmlElement = + div( + dl( + cls := "divide-y divide-gray-100", + items + ) + ) + + def field( + title: Node, + content: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "px-2 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0", + dt( + cls := "text-sm font-medium leading-6 text-gray-900", + title + ), + dd( + cls := "mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0", + content + ) + ) + + def files( + fs: Seq[FileRef], + fileMods: Option[(FileRef, Int) => HtmlMod] = None + )( + mods: HtmlMod* + )(using ComponentContext[?]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-100 rounded-md border border-gray-200", + fs.zipWithIndex + .map((f, i) => + fileMods match + case Some(fm) => file(f)(fm(f, i)) + case _ => file(f)() + ), + mods + ) + + def file(f: FileRef)(mods: HtmlMod*)(using + ComponentContext[?] + ): HtmlElement = + li( + cls := "flex items-center justify-between py-4 pl-4 pr-5 text-sm leading-6", + div( + cls := "flex w-0 flex-1 items-center", + icons.`paper-clip-solid`(), + div( + cls := "ml-4 flex min-w-0 flex-1 gap-2", + span( + cls := "truncate font-medium", + f.name + ), + f.sizeString.map(size => + span(cls := "flex-shrink-0 text-gray-400", size) + ) + ) + ), + div( + cls := "ml-4 flex-shrink-0", + a( + href := f.url, + target := "_blank", + cls := "font-medium text-indigo-600 hover:text-indigo-500", + UserMessage("file.download").asString + ) + ), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala new file mode 100644 index 0000000..e9d7f1b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala @@ -0,0 +1,202 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html +import io.laminext.syntax.core.* + +trait FormComponentsModule: + self: IconsModule => + + object forms: + + val inputClasses = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls("space-y-6 sm:space-y-5"), + div( + h3(cls("text-lg leading-6 font-medium text-gray-900"), title), + subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) + ), + div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) + ) + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: Modifier[HtmlElement]* + ): ReactiveHtmlElement[html.Label] = + L.label( + cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), + forId.map(id => L.forId(id)), + labelText, + if required then sup(cls("text-gray-400"), "* povinné pole") + else emptyMod, + mods + ) + + def field( + label: Modifier[HtmlElement] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls( + "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5" + ), + label, + div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) + ) + + def field( + id: String, + labelText: String, + input: HtmlElement, + help: Option[String] + ): HtmlElement = + field( + label(labelText, Some(id))() + )( + input.amend(idAttr(id)), + help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) + ) + + def form(mods: Modifier[HtmlElement]*)( + sections: Modifier[HtmlElement]* + )(actions: Modifier[HtmlElement]*): ReactiveHtmlElement[html.Form] = + L.form( + cls("space-y-8 divide-y divide-gray-200"), + mods, + sections, + div( + cls("pt-5"), + div(cls("flex justify-end"), actions) + ) + ) + + def inlineForm( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + def errorTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-red-600") + + def validationError(text: Modifier[HtmlElement]): HtmlElement = + p(errorTextMods, text) + + def helpTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-gray-500") + + def fieldHelp(text: Modifier[HtmlElement]): HtmlElement = + p(helpTextMods, text) + + def inputField( + id: String, + labelText: String, + placeholderText: Option[String] = None, + inputType: String = "text", + helpText: Option[String] = None + ): HtmlElement = + field( + id, + labelText, + input(id, inputType, placeholderText)(), + helpText + ) + + def input( + id: String, + inputType: String = "text", + placeholderText: Option[String] = None + )(mods: HtmlMod*): HtmlElement = + L.input( + cls(inputClasses), + idAttr(id), + nameAttr(id), + placeholderText.map(placeholder(_)), + tpe(inputType), + mods + ) + + def comboBoxSimple( + options: List[(String, String)], + selectedInitially: Option[String] = None, + id: Option[String] = None, + name: Option[String] = None + ): HtmlElement = + val expanded = Var(false) + val selected = Var(selectedInitially) + div( + cls("relative mt-2"), + L.input( + cls( + "w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + id.map(idAttr(_)), + name.map(nameAttr(_)), + tpe("text"), + role("combobox"), + aria.controls("options"), + aria.expanded <-- expanded + ), + button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + onClick.preventDefault --> (_ => expanded.toggle()) + ), + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + cls.toggle("hidden") <-- expanded.signal.not, + id.map(i => idAttr(s"${i}-options")), + role := "listbox", + // Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation. + // Active: "text-white bg-indigo-600", Not Active: "text-gray-900" + for (((v, l), i) <- options.zipWithIndex) + yield + val active = Var(false) + val isSelected = selected.signal.map(_.contains(v)) + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- active.signal, + cls.toggle("text-gray-900") <-- active.signal.not, + cls.toggle("font-semibold") <-- isSelected, + id.map(cid => idAttr(s"${cid}-option-${i}")), + role := "option", + tabIndex := -1, + // Selected: "font-semibold" + span(cls("block truncate"), l), + // Checkmark, only display for selected option. + // Active: "text-white", Not Active: "text-indigo-600" + isSelected.childWhenTrue( + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- active.signal.not, + cls.toggle("text-white") <-- active.signal, + icons.check(svg.cls("h-5 w-5")) + ) + ), + onClick.preventDefault.mapTo( + v + ) --> selected.writer.contramapSome, + onMouseEnter --> (_ => active.set(true)), + onMouseLeave --> (_ => active.set(false)) + ) + // More items... + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala new file mode 100644 index 0000000..6a44cce --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait HeadingsComponentsModule: + object headings: + def section(title: Node, actions: HtmlElement*): HtmlElement = + div( + cls("border-b border-gray-200 pb-5"), + div( + cls("sm:flex sm:items-center sm:justify-between"), + h3(cls("text-base font-semibold leading-6 text-gray-900"), title), + div( + cls("mt-3 flex sm:ml-4 sm:mt-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) + + def sectionWithSubtitle( + title: Node, + subtitle: Node, + actions: HtmlElement* + ): HtmlElement = + div( + cls("border-b border-gray-200 bg-white px-4 py-5 sm:px-6"), + div( + cls( + "-ml-4 -mt-4 flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls("ml-4 mt-4"), + h3( + cls("text-base font-semibold leading-6 text-gray-900"), + title + ), + p( + cls("mt-1 text-sm text-gray-500"), + subtitle + ) + ), + div( + cls("ml-4 mt-4 flex-shrink-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala new file mode 100644 index 0000000..be16e48 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala @@ -0,0 +1,321 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.codecs +import works.iterative.ui.components.laminar.CustomAttrs + +trait IconsModule: + object icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + def upload(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 24 24"), + path(d := "M0 0h24v24H0z", fill("none")), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + + def home(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 20 20"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + clipRule := "evenodd", + fillRule := "evenodd", + d := "M9.293 2.293a1 1 0 011.414 0l7 7A1 1 0 0117 11h-1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-3a1 1 0 00-1-1H9a1 1 0 00-1 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-6H3a1 1 0 01-.707-1.707l7-7z" + ) + ) + + def `x-mark-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + def `arrow-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18") + ) + ) + + def `chevron-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M15.75 19.5L8.25 12l7.5-7.5") + ) + ) + + def `chevron-right-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + clipRule("evenodd"), + fillRule("evenodd"), + d( + "M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" + ) + ) + ) + + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) + + def `paper-clip-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + cls := "flex-shrink-0 text-gray-400", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M15.621 4.379a3 3 0 00-4.242 0l-7 7a3 3 0 004.241 4.243h.001l.497-.5a.75.75 0 011.064 1.057l-.498.501-.002.002a4.5 4.5 0 01-6.364-6.364l7-7a4.5 4.5 0 016.368 6.36l-3.455 3.553A2.625 2.625 0 119.52 9.52l3.45-3.451a.75.75 0 111.061 1.06l-3.45 3.451a1.125 1.125 0 001.587 1.595l3.454-3.553a3 3 0 000-4.242z", + clipRule := "evenodd" + ) + ) + + def `exclamation-circle-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-warning`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-yellow-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-error`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-red-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z", + clipRule := "evenodd" + ) + ) + + def `alert-success`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-green-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z", + clipRule := "evenodd" + ) + ) + + def `alert-info`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-blue-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z", + clipRule := "evenodd" + ) + ) + + def spinner(mods: SvgMod*): SvgElement = svg( + withDefault(mods, cls("h-4 w-4")), + svgAttr("role", codecs.StringAsIsCodec, None) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + def arrowPath(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + fill("none"), + stroke("currentColor"), + strokeWidth("1.5"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d( + "M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" + ) + ) + ) + + def chevronUpDown(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z", + clipRule := "evenodd" + ) + ) + + def check(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z", + clipRule := "evenodd" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala new file mode 100644 index 0000000..70c4a7c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* + +trait LayoutModule: + object layout: + def cardMod: HtmlMod = cls("bg-white shadow px-4 py-5 sm:rounded-lg sm:p-6") + def card(content: Modifier[HtmlElement]*): HtmlElement = + div(cardMod, content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala new file mode 100644 index 0000000..5b64ad7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala @@ -0,0 +1,88 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait ListComponentsModule: + self: BadgeComponentsModule => + + object list: + def label( + text: String, + color: ColorKind + ): HtmlElement = badges.pill(text, color) + + def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None, + contentMod: Modifier[HtmlElement] = emptyMod + ): LI = + li( + cls("group"), + div( + contentMod, + cls( + "relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" + ), + avatar.map(a => + div( + cls("flex-shrink-0"), + div( + cls( + "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" + ), + a + ) + ) + ), + div( + cls("flex-1 min-w-0"), + p( + cls("text-sm font-medium text-gray-900"), + title, + span(cls("float-right"), right) + ), + subtitle.map(st => + p( + cls("text-sm text-gray-500 truncate"), + st + ) + ) + ) + ) + ) + + def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = + ul( + cls("relative z-0 divide-y divide-gray-200"), + role("list"), + children + ) + + def listSection( + header: String, + list: HtmlElement + ): Div = + div( + cls("relative"), + div( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + list + ) + + def navigation(sections: Modifier[HtmlElement]): HtmlElement = + navTag( + cls("flex-1 min-h-0 overflow-y-auto"), + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala new file mode 100644 index 0000000..d4a2c56 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ListContainerComponentsModule: + object listContainer: + def simpleWithDividers(items: HtmlMod*) = + ul( + role("list"), + cls("divide-y divide-gray-200"), + items.map(li(cls("py-4"), _)) + ) + + def cardWithDividers(items: HtmlMod*) = + div( + cls("overflow-hidden rounded-md bg-white shadow"), + ul( + role := "list", + cls("divide-y divide-gray-200"), + items.map( + li( + cls("px-6 py-4"), + _ + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala new file mode 100644 index 0000000..684f0fb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait ModalComponentsModule: + object modal: + def modalDialog( + content: Signal[Option[HtmlElement]], + isOpen: Signal[Boolean], + close: Observer[Unit] + ): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + onClick.preventDefault.mapTo(()) --> close + ) + ) + + div( + cls.toggle("hidden") <-- isOpen.not.combineWithFn(content)( + _ || _.isEmpty + ), + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-visible rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + child.maybe <-- content + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala new file mode 100644 index 0000000..866b529 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala @@ -0,0 +1,48 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* + +trait PageComponentsModule: + + object page: + def container( + children: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8 overflow-y-auto"), + children + ) + + def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement = + div( + cls("p-8 bg-gray-100 h-full"), + header, + children + ) + + def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + div( + cls("pb-5 border-b border-gray-200"), + div(cls("float-right"), right), + h1( + cls("text-2xl leading-6 font-medium text-gray-900"), + title + ), + subtitle.map( + p( + cls("text-sm font-medium text-gray-500"), + _ + ) + ) + ) + + def clickable: Modifier[HtmlElement] = + cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala new file mode 100644 index 0000000..3603d98 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait PanelComponentsModule: + object panel: + def basicCard(content: HtmlMod*) = + div( + cls("bg-white overflow-hidden shadow rounded-lg"), + div(cls("px-4 py-5 sm:p-6"), content) + ) + + /** Card, edge-to-edge on mobile */ + def cardEdgeToEdgeOnMobile(content: HtmlMod*) = + div( + cls("bg-white overflow-hidden shadow sm:rounded-lg"), + div(cls("px-4 py-5 sm:p-6"), content) + ) + + def cardWithHeader(header: HtmlMod*)(content: HtmlMod*) = + div( + cls( + "divide-y divide-gray-200 overflow-hidden rounded-lg bg-white shadow" + ), + div( + cls("px-4 py-5 sm:px-6"), + header + ), + div( + cls("px-4 py-5 sm:p-6"), + content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TableComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TableComponentsModule.scala new file mode 100644 index 0000000..8f57c10 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TableComponentsModule.scala @@ -0,0 +1,95 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableComponentsModule: + + object tables: + + def tableSection( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None, + actions: Modifier[HtmlElement]* + )( + table: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("px-4 sm:px-6 lg:px-8"), + div( + cls("sm:flex sm:items-center"), + div( + cls("sm:flex-auto"), + h1(cls("text-base font-semibold leading-6 text-gray-900"), title), + subtitle.map(st => p(cls("mt-2 text-sm text-gray-700"), st)) + ), + div(cls("mt-4 sm:ml-16 sm:mt-0 sm:flex-none"), actions) + ), + table + ) + + def tableContainer(table: ReactiveHtmlElement[html.Table]): HtmlElement = + div( + cls("mt-8 flow-root"), + div( + cls("-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"), + div( + cls("inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"), + table + ) + ) + ) + + def simpleTable(header: ReactiveHtmlElement[html.TableRow]*)( + body: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] = + table( + cls("min-w-full divide-y divide-gray-300"), + thead(header), + tbody(cls("divide-y divide-gray-200"), body) + ) + + def headerRow( + mods: HtmlMod* + )( + cells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] = + tr( + cells.zipWithIndex.map((c, i) => + if i == 0 then c.amend(cls("py-3.5 pl-4 pr-3 sm:pl-0")) + else if i == cells.length - 1 then + c.amend(cls("py-3.5 pr-4 pl-3 sm:pr-0")) + else c.amend(cls("py-3.5 px-3")) + ) + ) + + def dataRow( + mods: HtmlMod* + )( + cells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] = + tr( + mods, + cells.zipWithIndex.map((c, i) => + if i == 0 then c.amend(cls("py-4 pl-4 pr-3 sm:pl-0")) + else if i == cells.length - 1 then + c.amend(cls("py-4 pr-4 pl-3 sm:pr-0")) + else c.amend(cls("py-4 px-3")) + ) + ) + + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] = + th( + cls("text-left text-sm font-semibold text-gray-900"), + content + ) + + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] = + td( + cls("text-sm text-gray-500"), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUICatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUICatalogue.scala new file mode 100644 index 0000000..57ad709 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUICatalogue.scala @@ -0,0 +1,25 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +trait TailwindUICatalogueModule + extends BadgeComponentsModule + with ButtonComponentsModule + with HeadingsComponentsModule + with IconsModule + with FormComponentsModule + with LayoutModule + with ListComponentsModule + with ModalComponentsModule + with PageComponentsModule + with TableComponentsModule + with DetailComponentsModule + with ContainerComponentsModule + with PanelComponentsModule + with ListContainerComponentsModule + with AppShellComponentsModule + with UserMenuComponentsModule + with ZeroComponentsModule + with AlertComponentsModule + with ComboboxModule + with BreadcrumbsModule + +object TailwindUICatalogue extends TailwindUICatalogueModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormBuilderModule.scala new file mode 100644 index 0000000..81eb351 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormBuilderModule.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormMessagesResolver +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.forms.FormUIFactory +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait TailwindUIFormBuilderModule extends FormBuilderModule + +object TailwindUIFormBuilderModule: + given (using ctx: ComponentContext[_]): FormBuilderContext with + override def formMessagesResolver: FormMessagesResolver = + summon[FormMessagesResolver] + override def formUIFactory: FormUIFactory = TailwindUIFormUIFactory diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormUIFactory.scala new file mode 100644 index 0000000..3eea891 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormUIFactory.scala @@ -0,0 +1,161 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import works.iterative.ui.components.laminar.forms.FormUIFactory +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import TailwindUICatalogue.{forms, buttons, icons} +import org.scalajs.dom.html +import io.laminext.syntax.core.* +import com.raquo.laminar.tags.HtmlTag + +trait TailwindUIFormUIFactory extends FormUIFactory: + override def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] = + forms.form(mods*)(sections*)(actions*) + + override def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): L.HtmlElement = + forms.section(title, subtitle)(content*) + + override def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] = + forms.label(labelText, forId, required)(mods*) + + override def field(label: HtmlMod)(content: HtmlMod*): HtmlElement = + forms.field(label)(content*) + + override def submit(text: HtmlMod)(mods: HtmlMod*): HtmlElement = + buttons.primaryButton(text, None, None, "submit")(mods) + + override def cancel(text: HtmlMod)(mods: HtmlMod*): HtmlElement = + buttons.secondaryButton(text, None, None, "button")(cls("mr-3"), mods) + + override def validationError(text: HtmlMod): HtmlElement = + forms.validationError(text) + + override def errorTextMods: HtmlMod = forms.errorTextMods + + override def fieldHelp(text: HtmlMod): HtmlElement = + forms.fieldHelp(text) + + override def helpTextMods: HtmlMod = forms.helpTextMods + + private def renderInput[Ref <: org.scalajs.dom.HTMLElement]( + as: HtmlTag[Ref], + inError: Signal[Boolean], + mods: HtmlMod, + amendInput: ReactiveHtmlElement[Ref] => ReactiveHtmlElement[Ref] + ): Div = + div( + cls("relative w-full sm:max-w-xs rounded-md shadow-sm"), + amendInput( + as( + cls.toggle( + "text-red-900 ring-red-300 placeholder:text-red-300 focus:ring-red-500" + ) <-- inError, + cls.toggle( + "text-gray-900 ring-gray-300 placeholder:text-gray-400 focus:ring-indigo-600" + ) <-- inError.not, + cls( + "block w-full rounded-md border-0 py-1.5 shadow-sm ring-1 ring-inset focus:ring-2 focus:ring-inset sm:max-w-xs sm:text-sm sm:leading-6" + ), + mods + ) + ), + inError.childWhenTrue( + div( + cls( + "pointer-events-none absolute inset-y-0 right-0 pr-3 flex items-center" + ), + icons.`exclamation-circle-solid`(svg.cls("h-5 w-5 text-red-500")) + ) + ) + ) + + override def textarea( + inError: Signal[Boolean], + amendInput: TextArea => TextArea = identity + )(mods: HtmlMod*): HtmlElement = + renderInput(L.textArea, inError, mods, amendInput) + + override def input( + inError: Signal[Boolean], + amendInput: Input => Input = identity + )( + mods: HtmlMod* + ): HtmlElement = + renderInput(L.input, inError, mods, amendInput) + + override def select( + inError: Signal[Boolean], + amendInput: Select => Select = identity + )(mods: HtmlMod*): HtmlElement = + renderInput(L.select, inError, mods, amendInput) + override object combobox extends FormUIFactory.ComboboxComponents: + import works.iterative.ui.components.laminar.tailwind.ui.TailwindUICatalogue.combobox.simple + override def container( + inError: Signal[Boolean], + amendInput: Input => Input = identity + )(mods: HtmlMod*): HtmlElement = + simple.container(cls("sm:max-w-xs"), input(inError, amendInput)(), mods) + + override def button(mods: HtmlMod*): HtmlElement = simple.button(mods) + + override def options(mods: HtmlMod*): HtmlElement = simple.options(mods) + + override def option( + label: String, + isActive: Signal[Boolean], + isSelected: Signal[Boolean] + )(mods: HtmlMod*): HtmlElement = + simple.option(isActive, isSelected)( + simple.optionValue(label), + isSelected.childWhenTrue(simple.checkmark(isActive)) + ) + + override def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement = + val selectedFile: Var[Option[String]] = Var(None) + div( + cls := "mt-4 sm:mt-0 sm:flex-none", + L.label( + cls("block w-full"), + div( + buttonMods, + cls("cursor-pointer"), + buttons.sharedButtonMod, + buttons.secondaryButtonMod, + child <-- selectedFile.signal + .map(_.isDefined) + .switch( + icons.`paper-clip-solid`(svg.cls("w-6 h-6 mr-2")), + icons.upload(svg.cls("w-6 h-6 mr-2")) + ), + span(child.text <-- selectedFile.signal.map(_.getOrElse(title))) + ), + L.input( + cls("hidden"), + tpe("file"), + inputMods, + inContext(thisNode => + onInput + .mapTo( + thisNode.ref.files.headOption.map(_.name) + ) --> selectedFile.writer + ) + ) + ) + ) + +object TailwindUIFormUIFactory extends TailwindUIFormUIFactory diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala new file mode 100644 index 0000000..d2bd069 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala @@ -0,0 +1,160 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind +import works.iterative.ui.components.laminar.tailwind.color.ColorWeight + +trait AlertComponentsModule: + self: IconsModule => + object alerts: + // TODO: use context functions and builder pattern to build the alerts + def alert( + message: HtmlMod, + icon: SvgElement, + color: ColorKind, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + div( + cls := "rounded-md p-4", + color(50).bg, + div( + cls := "flex", + icon, + div( + cls := "ml-3", + title.map(t => + h3( + cls := "text-sm font-medium", + color(800).text, + t + ) + ), + div( + cls := "text-sm", + title.map(_ => cls("mt-2")), + color(700).text, + message + ), + actions.map { act => + div( + cls := "mt-4", + act(color) + ) + } + ), + mods(color) + ) + ) + + def warning( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-warning`(), + ColorKind.yellow, + title = title, + actions = actions, + mods = mods + ) + + def error( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-error`(), + ColorKind.red, + title = title, + actions = actions, + mods = mods + ) + + def success( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-success`(), + ColorKind.green, + title = title, + actions = actions, + mods = mods + ) + + def info( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-info`(), + ColorKind.blue, + title = title, + actions = actions, + mods = mods + ) + + def buttons(buttons: (ColorKind => HtmlMod)*): ColorKind => HtmlMod = + color => + div( + cls("-mx-2 -my-1.5 flex"), + buttons.map(_(color)) + ) + + def button(title: String)(mods: HtmlMod*): ColorKind => HtmlMod = color => + L.button( + tpe("button"), + cls( + "first:ml-0 ml-3 rounded-md px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(800).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + title, + mods + ) + + def closeMod(mods: HtmlMod): ColorKind => HtmlMod = color => + val closeIcon: SvgElement = + import svg.* + svg( + cls := "h-5 w-5", + viewBox := "0 0 20 20", + fill := "currentColor", + path( + d := "M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" + ) + ) + div( + cls("ml-auto pl-3"), + L.button( + cls( + "-mx-1.5 -my-1.5 inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(500).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + mods, + aria.hidden := true, + closeIcon + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala new file mode 100644 index 0000000..fa374fe --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala @@ -0,0 +1,52 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait AppShellComponentsModule: + object shell: + object stackedLight extends StackedAppShell: + override def navCls = cls("border-b border-gray-200 bg-white") + override def headerCls = emptyMod + override def pageWrapper(content: HtmlMod*): HtmlMod = + div(cls("py-10"), content) + + object stackedBranded extends StackedAppShell: + override def navCls = cls("bg-indigo-600") + override def headerCls = cls("bg-white shadown-sm") + override def pageWrapper(content: HtmlMod*): HtmlMod = content + +trait StackedAppShell: + protected def navCls: HtmlMod + protected def headerCls: HtmlMod + protected def pageWrapper(content: HtmlMod*): HtmlMod + + def apply(pageTitle: HtmlMod)(navbarItems: HtmlMod*)( + content: HtmlMod* + ): HtmlElement = + div( + cls("min-h-full"), + navTag( + navCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + div(cls("flex h-16 items-center justify-between"), navbarItems) + ) + ), + pageWrapper( + headerTag( + headerCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + h1( + cls( + "text-3xl font-bold leading-tight tracking-tight text-gray-900" + ), + pageTitle + ) + ) + ), + mainTag( + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala new file mode 100644 index 0000000..f6d7d7f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait BadgeComponentsModule: + + object badges: + def pill(name: String, color: ColorKind): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + name + ) + + def pill(name: String, color: Signal[ColorKind]): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + cls <-- color.map(_.apply(800).text.toCSS), + cls <-- color.map(_.apply(100).bg.toCSS), + name + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala new file mode 100644 index 0000000..03a2c9c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait BreadcrumbsModule: + self: IconsModule => + + object breadcrumbs: + def container(mods: HtmlMod*): HtmlElement = + navTag(cls("flex"), aria.label("Breadcrumb"), mods) + + def list(mods: HtmlMod*): HtmlElement = + ol(cls("flex items-center space-x-4"), role("list"), mods) + + def homeItem(mods: HtmlMod*): HtmlElement = + li( + div( + a( + href("#"), + cls("text-gray-400 hover:text-gray-500"), + icons.home(svg.cls("h-5 w-5 flex-shrink-0")), + span(cls("sr-only"), "Home"), + mods + ) + ) + ) + + def item(mods: HtmlMod*): HtmlElement = + li( + div( + cls("flex items-center"), + icons.`chevron-right-solid`( + svg.cls("h-5 w-5 flex-shrink-0 text-gray-400") + ), + a( + href("#"), + cls("ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala new file mode 100644 index 0000000..27f3cf2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala @@ -0,0 +1,102 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +trait ButtonComponentsModule: + + object buttons: + + private inline def srHelp(text: String): Modifier[HtmlElement] = + span(cls := "sr-only", text) + + val sharedButtonClasses = + "inline-flex justify-center py-2 px-4 border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + + val sharedButtonMod: HtmlMod = cls(sharedButtonClasses) + + val primaryButtonClasses = + "border-transparent text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300" + val primaryButtonMod: HtmlMod = cls(primaryButtonClasses) + + val secondaryButtonClasses = + "border-gray-300 text-gray-700 hover:bg-gray-50" + val secondaryButtonMod: HtmlMod = cls(secondaryButtonClasses) + + val neutralButtonClasses = + "border-gray-300 text-gray-700 bg-white hover:bg-gray-50" + val neutralButtonMod: HtmlMod = cls(neutralButtonClasses) + + val positiveButtonClasses = + "border-transparent text-white bg-green-600 hover:bg-green-700" + val positiveButtonMod: HtmlMod = cls(positiveButtonClasses) + + val negativeButtonClasses = + "border-transparent text-white bg-red-600 hover:bg-red-700" + val negativeButtonMod: HtmlMod = cls(negativeButtonClasses) + + def button( + text: Modifier[HtmlElement], + id: Option[String], + icon: Option[SvgElement] = None, + buttonType: String = "submit", + primary: Boolean = false + )(mods: HtmlMod*): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe(buttonType), + sharedButtonMod, + if primary then primaryButtonMod else secondaryButtonMod, + icon, + text, + mods + ) + + def primaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "submit" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, true)(mods*) + + def secondaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "button" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, false)(mods*) + + def inlineButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srText.map(srHelp(_)), + icon + ) + + def iconButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srText.map(srHelp(_)), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala new file mode 100644 index 0000000..ae4ab86 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala @@ -0,0 +1,56 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +import io.laminext.syntax.core.* + +trait ComboboxModule: + self: IconsModule => + + object combobox: + object simple: + def container(mods: HtmlMod*): Div = div(cls("relative mt-2"), mods) + + def button(mods: HtmlMod*): Button = + L.button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + mods + ) + + def options(mods: HtmlMod*) = + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + role := "listbox", + mods + ) + + def option(isActive: Signal[Boolean], isSelected: Signal[Boolean])( + mods: HtmlMod* + ) = + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- isActive, + cls.toggle("text-gray-900") <-- isActive.not, + cls.toggle("font-semibold") <-- isSelected, + mods + ) + + def optionValue(l: String): HtmlElement = + span(cls("block truncate"), l) + + def checkmark(isActive: Signal[Boolean]): HtmlElement = + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- isActive.not, + cls.toggle("text-white") <-- isActive, + icons.check(svg.cls("h-5 w-5")) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala new file mode 100644 index 0000000..f5a8873 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ContainerComponentsModule: + object container: + /** Full-width on mobile, constrained with padded content above */ + def default(content: HtmlMod*): HtmlElement = + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + + def padded(content: HtmlMod*): HtmlElement = + default(cls("px-4"), content) + + def narrow(content: HtmlMod*): HtmlElement = + padded( + div(cls("mx-auto max-w-3xl"), content) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala new file mode 100644 index 0000000..d601ab6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala @@ -0,0 +1,117 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.core.FileRef +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.laminar.* +import works.iterative.core.UserMessage + +trait DetailComponentsModule: + self: IconsModule => + object details: + def sectionHeader( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls( + "flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls := "px-4 sm:px-0", + h3( + cls := "text-base font-semibold leading-7 text-gray-900", + title + ), + subtitle.map(st => + p( + cls := "mt-1 max-w-2xl text-sm leading-6 text-gray-500", + st + ) + ) + ), + div(cls("flex-shrink-0"), actions) + ) + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + )(content: HtmlMod): HtmlElement = + div(sectionHeader(title, subtitle, actions), content) + + def fields(items: Node*): HtmlElement = + div( + dl( + cls := "divide-y divide-gray-100", + items + ) + ) + + def field( + title: Node, + content: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "px-2 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0", + dt( + cls := "text-sm font-medium leading-6 text-gray-900", + title + ), + dd( + cls := "mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0", + content + ) + ) + + def files( + fs: Seq[FileRef], + fileMods: Option[(FileRef, Int) => HtmlMod] = None + )( + mods: HtmlMod* + )(using ComponentContext[?]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-100 rounded-md border border-gray-200", + fs.zipWithIndex + .map((f, i) => + fileMods match + case Some(fm) => file(f)(fm(f, i)) + case _ => file(f)() + ), + mods + ) + + def file(f: FileRef)(mods: HtmlMod*)(using + ComponentContext[?] + ): HtmlElement = + li( + cls := "flex items-center justify-between py-4 pl-4 pr-5 text-sm leading-6", + div( + cls := "flex w-0 flex-1 items-center", + icons.`paper-clip-solid`(), + div( + cls := "ml-4 flex min-w-0 flex-1 gap-2", + span( + cls := "truncate font-medium", + f.name + ), + f.sizeString.map(size => + span(cls := "flex-shrink-0 text-gray-400", size) + ) + ) + ), + div( + cls := "ml-4 flex-shrink-0", + a( + href := f.url, + target := "_blank", + cls := "font-medium text-indigo-600 hover:text-indigo-500", + UserMessage("file.download").asString + ) + ), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala new file mode 100644 index 0000000..e9d7f1b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala @@ -0,0 +1,202 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html +import io.laminext.syntax.core.* + +trait FormComponentsModule: + self: IconsModule => + + object forms: + + val inputClasses = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls("space-y-6 sm:space-y-5"), + div( + h3(cls("text-lg leading-6 font-medium text-gray-900"), title), + subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) + ), + div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) + ) + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: Modifier[HtmlElement]* + ): ReactiveHtmlElement[html.Label] = + L.label( + cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), + forId.map(id => L.forId(id)), + labelText, + if required then sup(cls("text-gray-400"), "* povinné pole") + else emptyMod, + mods + ) + + def field( + label: Modifier[HtmlElement] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls( + "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5" + ), + label, + div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) + ) + + def field( + id: String, + labelText: String, + input: HtmlElement, + help: Option[String] + ): HtmlElement = + field( + label(labelText, Some(id))() + )( + input.amend(idAttr(id)), + help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) + ) + + def form(mods: Modifier[HtmlElement]*)( + sections: Modifier[HtmlElement]* + )(actions: Modifier[HtmlElement]*): ReactiveHtmlElement[html.Form] = + L.form( + cls("space-y-8 divide-y divide-gray-200"), + mods, + sections, + div( + cls("pt-5"), + div(cls("flex justify-end"), actions) + ) + ) + + def inlineForm( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + def errorTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-red-600") + + def validationError(text: Modifier[HtmlElement]): HtmlElement = + p(errorTextMods, text) + + def helpTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-gray-500") + + def fieldHelp(text: Modifier[HtmlElement]): HtmlElement = + p(helpTextMods, text) + + def inputField( + id: String, + labelText: String, + placeholderText: Option[String] = None, + inputType: String = "text", + helpText: Option[String] = None + ): HtmlElement = + field( + id, + labelText, + input(id, inputType, placeholderText)(), + helpText + ) + + def input( + id: String, + inputType: String = "text", + placeholderText: Option[String] = None + )(mods: HtmlMod*): HtmlElement = + L.input( + cls(inputClasses), + idAttr(id), + nameAttr(id), + placeholderText.map(placeholder(_)), + tpe(inputType), + mods + ) + + def comboBoxSimple( + options: List[(String, String)], + selectedInitially: Option[String] = None, + id: Option[String] = None, + name: Option[String] = None + ): HtmlElement = + val expanded = Var(false) + val selected = Var(selectedInitially) + div( + cls("relative mt-2"), + L.input( + cls( + "w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + id.map(idAttr(_)), + name.map(nameAttr(_)), + tpe("text"), + role("combobox"), + aria.controls("options"), + aria.expanded <-- expanded + ), + button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + onClick.preventDefault --> (_ => expanded.toggle()) + ), + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + cls.toggle("hidden") <-- expanded.signal.not, + id.map(i => idAttr(s"${i}-options")), + role := "listbox", + // Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation. + // Active: "text-white bg-indigo-600", Not Active: "text-gray-900" + for (((v, l), i) <- options.zipWithIndex) + yield + val active = Var(false) + val isSelected = selected.signal.map(_.contains(v)) + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- active.signal, + cls.toggle("text-gray-900") <-- active.signal.not, + cls.toggle("font-semibold") <-- isSelected, + id.map(cid => idAttr(s"${cid}-option-${i}")), + role := "option", + tabIndex := -1, + // Selected: "font-semibold" + span(cls("block truncate"), l), + // Checkmark, only display for selected option. + // Active: "text-white", Not Active: "text-indigo-600" + isSelected.childWhenTrue( + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- active.signal.not, + cls.toggle("text-white") <-- active.signal, + icons.check(svg.cls("h-5 w-5")) + ) + ), + onClick.preventDefault.mapTo( + v + ) --> selected.writer.contramapSome, + onMouseEnter --> (_ => active.set(true)), + onMouseLeave --> (_ => active.set(false)) + ) + // More items... + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala new file mode 100644 index 0000000..6a44cce --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait HeadingsComponentsModule: + object headings: + def section(title: Node, actions: HtmlElement*): HtmlElement = + div( + cls("border-b border-gray-200 pb-5"), + div( + cls("sm:flex sm:items-center sm:justify-between"), + h3(cls("text-base font-semibold leading-6 text-gray-900"), title), + div( + cls("mt-3 flex sm:ml-4 sm:mt-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) + + def sectionWithSubtitle( + title: Node, + subtitle: Node, + actions: HtmlElement* + ): HtmlElement = + div( + cls("border-b border-gray-200 bg-white px-4 py-5 sm:px-6"), + div( + cls( + "-ml-4 -mt-4 flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls("ml-4 mt-4"), + h3( + cls("text-base font-semibold leading-6 text-gray-900"), + title + ), + p( + cls("mt-1 text-sm text-gray-500"), + subtitle + ) + ), + div( + cls("ml-4 mt-4 flex-shrink-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala new file mode 100644 index 0000000..be16e48 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala @@ -0,0 +1,321 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.codecs +import works.iterative.ui.components.laminar.CustomAttrs + +trait IconsModule: + object icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + def upload(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 24 24"), + path(d := "M0 0h24v24H0z", fill("none")), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + + def home(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 20 20"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + clipRule := "evenodd", + fillRule := "evenodd", + d := "M9.293 2.293a1 1 0 011.414 0l7 7A1 1 0 0117 11h-1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-3a1 1 0 00-1-1H9a1 1 0 00-1 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-6H3a1 1 0 01-.707-1.707l7-7z" + ) + ) + + def `x-mark-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + def `arrow-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18") + ) + ) + + def `chevron-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M15.75 19.5L8.25 12l7.5-7.5") + ) + ) + + def `chevron-right-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + clipRule("evenodd"), + fillRule("evenodd"), + d( + "M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" + ) + ) + ) + + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) + + def `paper-clip-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + cls := "flex-shrink-0 text-gray-400", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M15.621 4.379a3 3 0 00-4.242 0l-7 7a3 3 0 004.241 4.243h.001l.497-.5a.75.75 0 011.064 1.057l-.498.501-.002.002a4.5 4.5 0 01-6.364-6.364l7-7a4.5 4.5 0 016.368 6.36l-3.455 3.553A2.625 2.625 0 119.52 9.52l3.45-3.451a.75.75 0 111.061 1.06l-3.45 3.451a1.125 1.125 0 001.587 1.595l3.454-3.553a3 3 0 000-4.242z", + clipRule := "evenodd" + ) + ) + + def `exclamation-circle-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-warning`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-yellow-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-error`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-red-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z", + clipRule := "evenodd" + ) + ) + + def `alert-success`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-green-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z", + clipRule := "evenodd" + ) + ) + + def `alert-info`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-blue-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z", + clipRule := "evenodd" + ) + ) + + def spinner(mods: SvgMod*): SvgElement = svg( + withDefault(mods, cls("h-4 w-4")), + svgAttr("role", codecs.StringAsIsCodec, None) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + def arrowPath(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + fill("none"), + stroke("currentColor"), + strokeWidth("1.5"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d( + "M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" + ) + ) + ) + + def chevronUpDown(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z", + clipRule := "evenodd" + ) + ) + + def check(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z", + clipRule := "evenodd" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala new file mode 100644 index 0000000..70c4a7c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* + +trait LayoutModule: + object layout: + def cardMod: HtmlMod = cls("bg-white shadow px-4 py-5 sm:rounded-lg sm:p-6") + def card(content: Modifier[HtmlElement]*): HtmlElement = + div(cardMod, content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala new file mode 100644 index 0000000..5b64ad7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala @@ -0,0 +1,88 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait ListComponentsModule: + self: BadgeComponentsModule => + + object list: + def label( + text: String, + color: ColorKind + ): HtmlElement = badges.pill(text, color) + + def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None, + contentMod: Modifier[HtmlElement] = emptyMod + ): LI = + li( + cls("group"), + div( + contentMod, + cls( + "relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" + ), + avatar.map(a => + div( + cls("flex-shrink-0"), + div( + cls( + "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" + ), + a + ) + ) + ), + div( + cls("flex-1 min-w-0"), + p( + cls("text-sm font-medium text-gray-900"), + title, + span(cls("float-right"), right) + ), + subtitle.map(st => + p( + cls("text-sm text-gray-500 truncate"), + st + ) + ) + ) + ) + ) + + def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = + ul( + cls("relative z-0 divide-y divide-gray-200"), + role("list"), + children + ) + + def listSection( + header: String, + list: HtmlElement + ): Div = + div( + cls("relative"), + div( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + list + ) + + def navigation(sections: Modifier[HtmlElement]): HtmlElement = + navTag( + cls("flex-1 min-h-0 overflow-y-auto"), + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala new file mode 100644 index 0000000..d4a2c56 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ListContainerComponentsModule: + object listContainer: + def simpleWithDividers(items: HtmlMod*) = + ul( + role("list"), + cls("divide-y divide-gray-200"), + items.map(li(cls("py-4"), _)) + ) + + def cardWithDividers(items: HtmlMod*) = + div( + cls("overflow-hidden rounded-md bg-white shadow"), + ul( + role := "list", + cls("divide-y divide-gray-200"), + items.map( + li( + cls("px-6 py-4"), + _ + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala new file mode 100644 index 0000000..684f0fb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait ModalComponentsModule: + object modal: + def modalDialog( + content: Signal[Option[HtmlElement]], + isOpen: Signal[Boolean], + close: Observer[Unit] + ): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + onClick.preventDefault.mapTo(()) --> close + ) + ) + + div( + cls.toggle("hidden") <-- isOpen.not.combineWithFn(content)( + _ || _.isEmpty + ), + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-visible rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + child.maybe <-- content + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala new file mode 100644 index 0000000..866b529 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala @@ -0,0 +1,48 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* + +trait PageComponentsModule: + + object page: + def container( + children: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8 overflow-y-auto"), + children + ) + + def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement = + div( + cls("p-8 bg-gray-100 h-full"), + header, + children + ) + + def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + div( + cls("pb-5 border-b border-gray-200"), + div(cls("float-right"), right), + h1( + cls("text-2xl leading-6 font-medium text-gray-900"), + title + ), + subtitle.map( + p( + cls("text-sm font-medium text-gray-500"), + _ + ) + ) + ) + + def clickable: Modifier[HtmlElement] = + cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala new file mode 100644 index 0000000..3603d98 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait PanelComponentsModule: + object panel: + def basicCard(content: HtmlMod*) = + div( + cls("bg-white overflow-hidden shadow rounded-lg"), + div(cls("px-4 py-5 sm:p-6"), content) + ) + + /** Card, edge-to-edge on mobile */ + def cardEdgeToEdgeOnMobile(content: HtmlMod*) = + div( + cls("bg-white overflow-hidden shadow sm:rounded-lg"), + div(cls("px-4 py-5 sm:p-6"), content) + ) + + def cardWithHeader(header: HtmlMod*)(content: HtmlMod*) = + div( + cls( + "divide-y divide-gray-200 overflow-hidden rounded-lg bg-white shadow" + ), + div( + cls("px-4 py-5 sm:px-6"), + header + ), + div( + cls("px-4 py-5 sm:p-6"), + content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TableComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TableComponentsModule.scala new file mode 100644 index 0000000..8f57c10 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TableComponentsModule.scala @@ -0,0 +1,95 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableComponentsModule: + + object tables: + + def tableSection( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None, + actions: Modifier[HtmlElement]* + )( + table: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("px-4 sm:px-6 lg:px-8"), + div( + cls("sm:flex sm:items-center"), + div( + cls("sm:flex-auto"), + h1(cls("text-base font-semibold leading-6 text-gray-900"), title), + subtitle.map(st => p(cls("mt-2 text-sm text-gray-700"), st)) + ), + div(cls("mt-4 sm:ml-16 sm:mt-0 sm:flex-none"), actions) + ), + table + ) + + def tableContainer(table: ReactiveHtmlElement[html.Table]): HtmlElement = + div( + cls("mt-8 flow-root"), + div( + cls("-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"), + div( + cls("inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"), + table + ) + ) + ) + + def simpleTable(header: ReactiveHtmlElement[html.TableRow]*)( + body: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] = + table( + cls("min-w-full divide-y divide-gray-300"), + thead(header), + tbody(cls("divide-y divide-gray-200"), body) + ) + + def headerRow( + mods: HtmlMod* + )( + cells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] = + tr( + cells.zipWithIndex.map((c, i) => + if i == 0 then c.amend(cls("py-3.5 pl-4 pr-3 sm:pl-0")) + else if i == cells.length - 1 then + c.amend(cls("py-3.5 pr-4 pl-3 sm:pr-0")) + else c.amend(cls("py-3.5 px-3")) + ) + ) + + def dataRow( + mods: HtmlMod* + )( + cells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] = + tr( + mods, + cells.zipWithIndex.map((c, i) => + if i == 0 then c.amend(cls("py-4 pl-4 pr-3 sm:pl-0")) + else if i == cells.length - 1 then + c.amend(cls("py-4 pr-4 pl-3 sm:pr-0")) + else c.amend(cls("py-4 px-3")) + ) + ) + + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] = + th( + cls("text-left text-sm font-semibold text-gray-900"), + content + ) + + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] = + td( + cls("text-sm text-gray-500"), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUICatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUICatalogue.scala new file mode 100644 index 0000000..57ad709 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUICatalogue.scala @@ -0,0 +1,25 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +trait TailwindUICatalogueModule + extends BadgeComponentsModule + with ButtonComponentsModule + with HeadingsComponentsModule + with IconsModule + with FormComponentsModule + with LayoutModule + with ListComponentsModule + with ModalComponentsModule + with PageComponentsModule + with TableComponentsModule + with DetailComponentsModule + with ContainerComponentsModule + with PanelComponentsModule + with ListContainerComponentsModule + with AppShellComponentsModule + with UserMenuComponentsModule + with ZeroComponentsModule + with AlertComponentsModule + with ComboboxModule + with BreadcrumbsModule + +object TailwindUICatalogue extends TailwindUICatalogueModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormBuilderModule.scala new file mode 100644 index 0000000..81eb351 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormBuilderModule.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormMessagesResolver +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.forms.FormUIFactory +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait TailwindUIFormBuilderModule extends FormBuilderModule + +object TailwindUIFormBuilderModule: + given (using ctx: ComponentContext[_]): FormBuilderContext with + override def formMessagesResolver: FormMessagesResolver = + summon[FormMessagesResolver] + override def formUIFactory: FormUIFactory = TailwindUIFormUIFactory diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormUIFactory.scala new file mode 100644 index 0000000..3eea891 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormUIFactory.scala @@ -0,0 +1,161 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import works.iterative.ui.components.laminar.forms.FormUIFactory +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import TailwindUICatalogue.{forms, buttons, icons} +import org.scalajs.dom.html +import io.laminext.syntax.core.* +import com.raquo.laminar.tags.HtmlTag + +trait TailwindUIFormUIFactory extends FormUIFactory: + override def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] = + forms.form(mods*)(sections*)(actions*) + + override def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): L.HtmlElement = + forms.section(title, subtitle)(content*) + + override def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] = + forms.label(labelText, forId, required)(mods*) + + override def field(label: HtmlMod)(content: HtmlMod*): HtmlElement = + forms.field(label)(content*) + + override def submit(text: HtmlMod)(mods: HtmlMod*): HtmlElement = + buttons.primaryButton(text, None, None, "submit")(mods) + + override def cancel(text: HtmlMod)(mods: HtmlMod*): HtmlElement = + buttons.secondaryButton(text, None, None, "button")(cls("mr-3"), mods) + + override def validationError(text: HtmlMod): HtmlElement = + forms.validationError(text) + + override def errorTextMods: HtmlMod = forms.errorTextMods + + override def fieldHelp(text: HtmlMod): HtmlElement = + forms.fieldHelp(text) + + override def helpTextMods: HtmlMod = forms.helpTextMods + + private def renderInput[Ref <: org.scalajs.dom.HTMLElement]( + as: HtmlTag[Ref], + inError: Signal[Boolean], + mods: HtmlMod, + amendInput: ReactiveHtmlElement[Ref] => ReactiveHtmlElement[Ref] + ): Div = + div( + cls("relative w-full sm:max-w-xs rounded-md shadow-sm"), + amendInput( + as( + cls.toggle( + "text-red-900 ring-red-300 placeholder:text-red-300 focus:ring-red-500" + ) <-- inError, + cls.toggle( + "text-gray-900 ring-gray-300 placeholder:text-gray-400 focus:ring-indigo-600" + ) <-- inError.not, + cls( + "block w-full rounded-md border-0 py-1.5 shadow-sm ring-1 ring-inset focus:ring-2 focus:ring-inset sm:max-w-xs sm:text-sm sm:leading-6" + ), + mods + ) + ), + inError.childWhenTrue( + div( + cls( + "pointer-events-none absolute inset-y-0 right-0 pr-3 flex items-center" + ), + icons.`exclamation-circle-solid`(svg.cls("h-5 w-5 text-red-500")) + ) + ) + ) + + override def textarea( + inError: Signal[Boolean], + amendInput: TextArea => TextArea = identity + )(mods: HtmlMod*): HtmlElement = + renderInput(L.textArea, inError, mods, amendInput) + + override def input( + inError: Signal[Boolean], + amendInput: Input => Input = identity + )( + mods: HtmlMod* + ): HtmlElement = + renderInput(L.input, inError, mods, amendInput) + + override def select( + inError: Signal[Boolean], + amendInput: Select => Select = identity + )(mods: HtmlMod*): HtmlElement = + renderInput(L.select, inError, mods, amendInput) + override object combobox extends FormUIFactory.ComboboxComponents: + import works.iterative.ui.components.laminar.tailwind.ui.TailwindUICatalogue.combobox.simple + override def container( + inError: Signal[Boolean], + amendInput: Input => Input = identity + )(mods: HtmlMod*): HtmlElement = + simple.container(cls("sm:max-w-xs"), input(inError, amendInput)(), mods) + + override def button(mods: HtmlMod*): HtmlElement = simple.button(mods) + + override def options(mods: HtmlMod*): HtmlElement = simple.options(mods) + + override def option( + label: String, + isActive: Signal[Boolean], + isSelected: Signal[Boolean] + )(mods: HtmlMod*): HtmlElement = + simple.option(isActive, isSelected)( + simple.optionValue(label), + isSelected.childWhenTrue(simple.checkmark(isActive)) + ) + + override def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement = + val selectedFile: Var[Option[String]] = Var(None) + div( + cls := "mt-4 sm:mt-0 sm:flex-none", + L.label( + cls("block w-full"), + div( + buttonMods, + cls("cursor-pointer"), + buttons.sharedButtonMod, + buttons.secondaryButtonMod, + child <-- selectedFile.signal + .map(_.isDefined) + .switch( + icons.`paper-clip-solid`(svg.cls("w-6 h-6 mr-2")), + icons.upload(svg.cls("w-6 h-6 mr-2")) + ), + span(child.text <-- selectedFile.signal.map(_.getOrElse(title))) + ), + L.input( + cls("hidden"), + tpe("file"), + inputMods, + inContext(thisNode => + onInput + .mapTo( + thisNode.ref.files.headOption.map(_.name) + ) --> selectedFile.writer + ) + ) + ) + ) + +object TailwindUIFormUIFactory extends TailwindUIFormUIFactory diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/UserMenuComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/UserMenuComponentsModule.scala new file mode 100644 index 0000000..71d382b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/UserMenuComponentsModule.scala @@ -0,0 +1,55 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait UserMenuComponentsModule: + object userMenu: + def navBarItem(button: HtmlElement, popup: HtmlElement): HtmlElement = + div( + cls("relative ml-3"), + div(button), + popup + ) + + def userName(name: String): HtmlElement = + span(cls("text-white"), name) + + def avatar(href: String): HtmlElement = + img( + src(href), + cls("h-8 w-8 rounded-full") + ) + + def menuButton(userDetails: Node*): HtmlElement = + button( + tpe("button"), + cls( + "flex max-w-xs items-center rounded-full bg-indigo-600 text-sm focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-indigo-600" + ), + idAttr("user-menu-button"), + aria.hasPopup(true), + userDetails, + span(cls("sr-only"), "Open user menu") + ) + + def popup(menuItems: HtmlElement*): HtmlElement = + div( + cls( + "absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none" + ), + role("menu"), + aria.orientation("vertical"), + aria.labelledBy("user-menu-button"), + tabIndex(-1), + menuItems + ) + + def menuItem(id: String, label: Node): HtmlElement = + a( + cls("block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"), + href("#"), + role("menuitem"), + tabIndex(-1), + idAttr(s"user-menu-item-${id}"), + label + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala new file mode 100644 index 0000000..d2bd069 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala @@ -0,0 +1,160 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind +import works.iterative.ui.components.laminar.tailwind.color.ColorWeight + +trait AlertComponentsModule: + self: IconsModule => + object alerts: + // TODO: use context functions and builder pattern to build the alerts + def alert( + message: HtmlMod, + icon: SvgElement, + color: ColorKind, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + div( + cls := "rounded-md p-4", + color(50).bg, + div( + cls := "flex", + icon, + div( + cls := "ml-3", + title.map(t => + h3( + cls := "text-sm font-medium", + color(800).text, + t + ) + ), + div( + cls := "text-sm", + title.map(_ => cls("mt-2")), + color(700).text, + message + ), + actions.map { act => + div( + cls := "mt-4", + act(color) + ) + } + ), + mods(color) + ) + ) + + def warning( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-warning`(), + ColorKind.yellow, + title = title, + actions = actions, + mods = mods + ) + + def error( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-error`(), + ColorKind.red, + title = title, + actions = actions, + mods = mods + ) + + def success( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-success`(), + ColorKind.green, + title = title, + actions = actions, + mods = mods + ) + + def info( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-info`(), + ColorKind.blue, + title = title, + actions = actions, + mods = mods + ) + + def buttons(buttons: (ColorKind => HtmlMod)*): ColorKind => HtmlMod = + color => + div( + cls("-mx-2 -my-1.5 flex"), + buttons.map(_(color)) + ) + + def button(title: String)(mods: HtmlMod*): ColorKind => HtmlMod = color => + L.button( + tpe("button"), + cls( + "first:ml-0 ml-3 rounded-md px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(800).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + title, + mods + ) + + def closeMod(mods: HtmlMod): ColorKind => HtmlMod = color => + val closeIcon: SvgElement = + import svg.* + svg( + cls := "h-5 w-5", + viewBox := "0 0 20 20", + fill := "currentColor", + path( + d := "M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" + ) + ) + div( + cls("ml-auto pl-3"), + L.button( + cls( + "-mx-1.5 -my-1.5 inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(500).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + mods, + aria.hidden := true, + closeIcon + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala new file mode 100644 index 0000000..fa374fe --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala @@ -0,0 +1,52 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait AppShellComponentsModule: + object shell: + object stackedLight extends StackedAppShell: + override def navCls = cls("border-b border-gray-200 bg-white") + override def headerCls = emptyMod + override def pageWrapper(content: HtmlMod*): HtmlMod = + div(cls("py-10"), content) + + object stackedBranded extends StackedAppShell: + override def navCls = cls("bg-indigo-600") + override def headerCls = cls("bg-white shadown-sm") + override def pageWrapper(content: HtmlMod*): HtmlMod = content + +trait StackedAppShell: + protected def navCls: HtmlMod + protected def headerCls: HtmlMod + protected def pageWrapper(content: HtmlMod*): HtmlMod + + def apply(pageTitle: HtmlMod)(navbarItems: HtmlMod*)( + content: HtmlMod* + ): HtmlElement = + div( + cls("min-h-full"), + navTag( + navCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + div(cls("flex h-16 items-center justify-between"), navbarItems) + ) + ), + pageWrapper( + headerTag( + headerCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + h1( + cls( + "text-3xl font-bold leading-tight tracking-tight text-gray-900" + ), + pageTitle + ) + ) + ), + mainTag( + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala new file mode 100644 index 0000000..f6d7d7f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait BadgeComponentsModule: + + object badges: + def pill(name: String, color: ColorKind): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + name + ) + + def pill(name: String, color: Signal[ColorKind]): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + cls <-- color.map(_.apply(800).text.toCSS), + cls <-- color.map(_.apply(100).bg.toCSS), + name + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala new file mode 100644 index 0000000..03a2c9c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait BreadcrumbsModule: + self: IconsModule => + + object breadcrumbs: + def container(mods: HtmlMod*): HtmlElement = + navTag(cls("flex"), aria.label("Breadcrumb"), mods) + + def list(mods: HtmlMod*): HtmlElement = + ol(cls("flex items-center space-x-4"), role("list"), mods) + + def homeItem(mods: HtmlMod*): HtmlElement = + li( + div( + a( + href("#"), + cls("text-gray-400 hover:text-gray-500"), + icons.home(svg.cls("h-5 w-5 flex-shrink-0")), + span(cls("sr-only"), "Home"), + mods + ) + ) + ) + + def item(mods: HtmlMod*): HtmlElement = + li( + div( + cls("flex items-center"), + icons.`chevron-right-solid`( + svg.cls("h-5 w-5 flex-shrink-0 text-gray-400") + ), + a( + href("#"), + cls("ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala new file mode 100644 index 0000000..27f3cf2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala @@ -0,0 +1,102 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +trait ButtonComponentsModule: + + object buttons: + + private inline def srHelp(text: String): Modifier[HtmlElement] = + span(cls := "sr-only", text) + + val sharedButtonClasses = + "inline-flex justify-center py-2 px-4 border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + + val sharedButtonMod: HtmlMod = cls(sharedButtonClasses) + + val primaryButtonClasses = + "border-transparent text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300" + val primaryButtonMod: HtmlMod = cls(primaryButtonClasses) + + val secondaryButtonClasses = + "border-gray-300 text-gray-700 hover:bg-gray-50" + val secondaryButtonMod: HtmlMod = cls(secondaryButtonClasses) + + val neutralButtonClasses = + "border-gray-300 text-gray-700 bg-white hover:bg-gray-50" + val neutralButtonMod: HtmlMod = cls(neutralButtonClasses) + + val positiveButtonClasses = + "border-transparent text-white bg-green-600 hover:bg-green-700" + val positiveButtonMod: HtmlMod = cls(positiveButtonClasses) + + val negativeButtonClasses = + "border-transparent text-white bg-red-600 hover:bg-red-700" + val negativeButtonMod: HtmlMod = cls(negativeButtonClasses) + + def button( + text: Modifier[HtmlElement], + id: Option[String], + icon: Option[SvgElement] = None, + buttonType: String = "submit", + primary: Boolean = false + )(mods: HtmlMod*): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe(buttonType), + sharedButtonMod, + if primary then primaryButtonMod else secondaryButtonMod, + icon, + text, + mods + ) + + def primaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "submit" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, true)(mods*) + + def secondaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "button" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, false)(mods*) + + def inlineButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srText.map(srHelp(_)), + icon + ) + + def iconButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srText.map(srHelp(_)), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala new file mode 100644 index 0000000..ae4ab86 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala @@ -0,0 +1,56 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +import io.laminext.syntax.core.* + +trait ComboboxModule: + self: IconsModule => + + object combobox: + object simple: + def container(mods: HtmlMod*): Div = div(cls("relative mt-2"), mods) + + def button(mods: HtmlMod*): Button = + L.button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + mods + ) + + def options(mods: HtmlMod*) = + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + role := "listbox", + mods + ) + + def option(isActive: Signal[Boolean], isSelected: Signal[Boolean])( + mods: HtmlMod* + ) = + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- isActive, + cls.toggle("text-gray-900") <-- isActive.not, + cls.toggle("font-semibold") <-- isSelected, + mods + ) + + def optionValue(l: String): HtmlElement = + span(cls("block truncate"), l) + + def checkmark(isActive: Signal[Boolean]): HtmlElement = + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- isActive.not, + cls.toggle("text-white") <-- isActive, + icons.check(svg.cls("h-5 w-5")) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala new file mode 100644 index 0000000..f5a8873 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ContainerComponentsModule: + object container: + /** Full-width on mobile, constrained with padded content above */ + def default(content: HtmlMod*): HtmlElement = + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + + def padded(content: HtmlMod*): HtmlElement = + default(cls("px-4"), content) + + def narrow(content: HtmlMod*): HtmlElement = + padded( + div(cls("mx-auto max-w-3xl"), content) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala new file mode 100644 index 0000000..d601ab6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala @@ -0,0 +1,117 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.core.FileRef +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.laminar.* +import works.iterative.core.UserMessage + +trait DetailComponentsModule: + self: IconsModule => + object details: + def sectionHeader( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls( + "flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls := "px-4 sm:px-0", + h3( + cls := "text-base font-semibold leading-7 text-gray-900", + title + ), + subtitle.map(st => + p( + cls := "mt-1 max-w-2xl text-sm leading-6 text-gray-500", + st + ) + ) + ), + div(cls("flex-shrink-0"), actions) + ) + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + )(content: HtmlMod): HtmlElement = + div(sectionHeader(title, subtitle, actions), content) + + def fields(items: Node*): HtmlElement = + div( + dl( + cls := "divide-y divide-gray-100", + items + ) + ) + + def field( + title: Node, + content: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "px-2 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0", + dt( + cls := "text-sm font-medium leading-6 text-gray-900", + title + ), + dd( + cls := "mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0", + content + ) + ) + + def files( + fs: Seq[FileRef], + fileMods: Option[(FileRef, Int) => HtmlMod] = None + )( + mods: HtmlMod* + )(using ComponentContext[?]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-100 rounded-md border border-gray-200", + fs.zipWithIndex + .map((f, i) => + fileMods match + case Some(fm) => file(f)(fm(f, i)) + case _ => file(f)() + ), + mods + ) + + def file(f: FileRef)(mods: HtmlMod*)(using + ComponentContext[?] + ): HtmlElement = + li( + cls := "flex items-center justify-between py-4 pl-4 pr-5 text-sm leading-6", + div( + cls := "flex w-0 flex-1 items-center", + icons.`paper-clip-solid`(), + div( + cls := "ml-4 flex min-w-0 flex-1 gap-2", + span( + cls := "truncate font-medium", + f.name + ), + f.sizeString.map(size => + span(cls := "flex-shrink-0 text-gray-400", size) + ) + ) + ), + div( + cls := "ml-4 flex-shrink-0", + a( + href := f.url, + target := "_blank", + cls := "font-medium text-indigo-600 hover:text-indigo-500", + UserMessage("file.download").asString + ) + ), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala new file mode 100644 index 0000000..e9d7f1b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala @@ -0,0 +1,202 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html +import io.laminext.syntax.core.* + +trait FormComponentsModule: + self: IconsModule => + + object forms: + + val inputClasses = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls("space-y-6 sm:space-y-5"), + div( + h3(cls("text-lg leading-6 font-medium text-gray-900"), title), + subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) + ), + div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) + ) + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: Modifier[HtmlElement]* + ): ReactiveHtmlElement[html.Label] = + L.label( + cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), + forId.map(id => L.forId(id)), + labelText, + if required then sup(cls("text-gray-400"), "* povinné pole") + else emptyMod, + mods + ) + + def field( + label: Modifier[HtmlElement] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls( + "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5" + ), + label, + div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) + ) + + def field( + id: String, + labelText: String, + input: HtmlElement, + help: Option[String] + ): HtmlElement = + field( + label(labelText, Some(id))() + )( + input.amend(idAttr(id)), + help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) + ) + + def form(mods: Modifier[HtmlElement]*)( + sections: Modifier[HtmlElement]* + )(actions: Modifier[HtmlElement]*): ReactiveHtmlElement[html.Form] = + L.form( + cls("space-y-8 divide-y divide-gray-200"), + mods, + sections, + div( + cls("pt-5"), + div(cls("flex justify-end"), actions) + ) + ) + + def inlineForm( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + def errorTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-red-600") + + def validationError(text: Modifier[HtmlElement]): HtmlElement = + p(errorTextMods, text) + + def helpTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-gray-500") + + def fieldHelp(text: Modifier[HtmlElement]): HtmlElement = + p(helpTextMods, text) + + def inputField( + id: String, + labelText: String, + placeholderText: Option[String] = None, + inputType: String = "text", + helpText: Option[String] = None + ): HtmlElement = + field( + id, + labelText, + input(id, inputType, placeholderText)(), + helpText + ) + + def input( + id: String, + inputType: String = "text", + placeholderText: Option[String] = None + )(mods: HtmlMod*): HtmlElement = + L.input( + cls(inputClasses), + idAttr(id), + nameAttr(id), + placeholderText.map(placeholder(_)), + tpe(inputType), + mods + ) + + def comboBoxSimple( + options: List[(String, String)], + selectedInitially: Option[String] = None, + id: Option[String] = None, + name: Option[String] = None + ): HtmlElement = + val expanded = Var(false) + val selected = Var(selectedInitially) + div( + cls("relative mt-2"), + L.input( + cls( + "w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + id.map(idAttr(_)), + name.map(nameAttr(_)), + tpe("text"), + role("combobox"), + aria.controls("options"), + aria.expanded <-- expanded + ), + button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + onClick.preventDefault --> (_ => expanded.toggle()) + ), + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + cls.toggle("hidden") <-- expanded.signal.not, + id.map(i => idAttr(s"${i}-options")), + role := "listbox", + // Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation. + // Active: "text-white bg-indigo-600", Not Active: "text-gray-900" + for (((v, l), i) <- options.zipWithIndex) + yield + val active = Var(false) + val isSelected = selected.signal.map(_.contains(v)) + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- active.signal, + cls.toggle("text-gray-900") <-- active.signal.not, + cls.toggle("font-semibold") <-- isSelected, + id.map(cid => idAttr(s"${cid}-option-${i}")), + role := "option", + tabIndex := -1, + // Selected: "font-semibold" + span(cls("block truncate"), l), + // Checkmark, only display for selected option. + // Active: "text-white", Not Active: "text-indigo-600" + isSelected.childWhenTrue( + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- active.signal.not, + cls.toggle("text-white") <-- active.signal, + icons.check(svg.cls("h-5 w-5")) + ) + ), + onClick.preventDefault.mapTo( + v + ) --> selected.writer.contramapSome, + onMouseEnter --> (_ => active.set(true)), + onMouseLeave --> (_ => active.set(false)) + ) + // More items... + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala new file mode 100644 index 0000000..6a44cce --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait HeadingsComponentsModule: + object headings: + def section(title: Node, actions: HtmlElement*): HtmlElement = + div( + cls("border-b border-gray-200 pb-5"), + div( + cls("sm:flex sm:items-center sm:justify-between"), + h3(cls("text-base font-semibold leading-6 text-gray-900"), title), + div( + cls("mt-3 flex sm:ml-4 sm:mt-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) + + def sectionWithSubtitle( + title: Node, + subtitle: Node, + actions: HtmlElement* + ): HtmlElement = + div( + cls("border-b border-gray-200 bg-white px-4 py-5 sm:px-6"), + div( + cls( + "-ml-4 -mt-4 flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls("ml-4 mt-4"), + h3( + cls("text-base font-semibold leading-6 text-gray-900"), + title + ), + p( + cls("mt-1 text-sm text-gray-500"), + subtitle + ) + ), + div( + cls("ml-4 mt-4 flex-shrink-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala new file mode 100644 index 0000000..be16e48 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala @@ -0,0 +1,321 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.codecs +import works.iterative.ui.components.laminar.CustomAttrs + +trait IconsModule: + object icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + def upload(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 24 24"), + path(d := "M0 0h24v24H0z", fill("none")), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + + def home(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 20 20"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + clipRule := "evenodd", + fillRule := "evenodd", + d := "M9.293 2.293a1 1 0 011.414 0l7 7A1 1 0 0117 11h-1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-3a1 1 0 00-1-1H9a1 1 0 00-1 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-6H3a1 1 0 01-.707-1.707l7-7z" + ) + ) + + def `x-mark-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + def `arrow-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18") + ) + ) + + def `chevron-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M15.75 19.5L8.25 12l7.5-7.5") + ) + ) + + def `chevron-right-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + clipRule("evenodd"), + fillRule("evenodd"), + d( + "M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" + ) + ) + ) + + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) + + def `paper-clip-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + cls := "flex-shrink-0 text-gray-400", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M15.621 4.379a3 3 0 00-4.242 0l-7 7a3 3 0 004.241 4.243h.001l.497-.5a.75.75 0 011.064 1.057l-.498.501-.002.002a4.5 4.5 0 01-6.364-6.364l7-7a4.5 4.5 0 016.368 6.36l-3.455 3.553A2.625 2.625 0 119.52 9.52l3.45-3.451a.75.75 0 111.061 1.06l-3.45 3.451a1.125 1.125 0 001.587 1.595l3.454-3.553a3 3 0 000-4.242z", + clipRule := "evenodd" + ) + ) + + def `exclamation-circle-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-warning`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-yellow-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-error`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-red-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z", + clipRule := "evenodd" + ) + ) + + def `alert-success`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-green-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z", + clipRule := "evenodd" + ) + ) + + def `alert-info`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-blue-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z", + clipRule := "evenodd" + ) + ) + + def spinner(mods: SvgMod*): SvgElement = svg( + withDefault(mods, cls("h-4 w-4")), + svgAttr("role", codecs.StringAsIsCodec, None) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + def arrowPath(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + fill("none"), + stroke("currentColor"), + strokeWidth("1.5"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d( + "M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" + ) + ) + ) + + def chevronUpDown(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z", + clipRule := "evenodd" + ) + ) + + def check(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z", + clipRule := "evenodd" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala new file mode 100644 index 0000000..70c4a7c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* + +trait LayoutModule: + object layout: + def cardMod: HtmlMod = cls("bg-white shadow px-4 py-5 sm:rounded-lg sm:p-6") + def card(content: Modifier[HtmlElement]*): HtmlElement = + div(cardMod, content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala new file mode 100644 index 0000000..5b64ad7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala @@ -0,0 +1,88 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait ListComponentsModule: + self: BadgeComponentsModule => + + object list: + def label( + text: String, + color: ColorKind + ): HtmlElement = badges.pill(text, color) + + def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None, + contentMod: Modifier[HtmlElement] = emptyMod + ): LI = + li( + cls("group"), + div( + contentMod, + cls( + "relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" + ), + avatar.map(a => + div( + cls("flex-shrink-0"), + div( + cls( + "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" + ), + a + ) + ) + ), + div( + cls("flex-1 min-w-0"), + p( + cls("text-sm font-medium text-gray-900"), + title, + span(cls("float-right"), right) + ), + subtitle.map(st => + p( + cls("text-sm text-gray-500 truncate"), + st + ) + ) + ) + ) + ) + + def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = + ul( + cls("relative z-0 divide-y divide-gray-200"), + role("list"), + children + ) + + def listSection( + header: String, + list: HtmlElement + ): Div = + div( + cls("relative"), + div( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + list + ) + + def navigation(sections: Modifier[HtmlElement]): HtmlElement = + navTag( + cls("flex-1 min-h-0 overflow-y-auto"), + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala new file mode 100644 index 0000000..d4a2c56 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ListContainerComponentsModule: + object listContainer: + def simpleWithDividers(items: HtmlMod*) = + ul( + role("list"), + cls("divide-y divide-gray-200"), + items.map(li(cls("py-4"), _)) + ) + + def cardWithDividers(items: HtmlMod*) = + div( + cls("overflow-hidden rounded-md bg-white shadow"), + ul( + role := "list", + cls("divide-y divide-gray-200"), + items.map( + li( + cls("px-6 py-4"), + _ + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala new file mode 100644 index 0000000..684f0fb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait ModalComponentsModule: + object modal: + def modalDialog( + content: Signal[Option[HtmlElement]], + isOpen: Signal[Boolean], + close: Observer[Unit] + ): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + onClick.preventDefault.mapTo(()) --> close + ) + ) + + div( + cls.toggle("hidden") <-- isOpen.not.combineWithFn(content)( + _ || _.isEmpty + ), + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-visible rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + child.maybe <-- content + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala new file mode 100644 index 0000000..866b529 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala @@ -0,0 +1,48 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* + +trait PageComponentsModule: + + object page: + def container( + children: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8 overflow-y-auto"), + children + ) + + def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement = + div( + cls("p-8 bg-gray-100 h-full"), + header, + children + ) + + def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + div( + cls("pb-5 border-b border-gray-200"), + div(cls("float-right"), right), + h1( + cls("text-2xl leading-6 font-medium text-gray-900"), + title + ), + subtitle.map( + p( + cls("text-sm font-medium text-gray-500"), + _ + ) + ) + ) + + def clickable: Modifier[HtmlElement] = + cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala new file mode 100644 index 0000000..3603d98 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait PanelComponentsModule: + object panel: + def basicCard(content: HtmlMod*) = + div( + cls("bg-white overflow-hidden shadow rounded-lg"), + div(cls("px-4 py-5 sm:p-6"), content) + ) + + /** Card, edge-to-edge on mobile */ + def cardEdgeToEdgeOnMobile(content: HtmlMod*) = + div( + cls("bg-white overflow-hidden shadow sm:rounded-lg"), + div(cls("px-4 py-5 sm:p-6"), content) + ) + + def cardWithHeader(header: HtmlMod*)(content: HtmlMod*) = + div( + cls( + "divide-y divide-gray-200 overflow-hidden rounded-lg bg-white shadow" + ), + div( + cls("px-4 py-5 sm:px-6"), + header + ), + div( + cls("px-4 py-5 sm:p-6"), + content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TableComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TableComponentsModule.scala new file mode 100644 index 0000000..8f57c10 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TableComponentsModule.scala @@ -0,0 +1,95 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableComponentsModule: + + object tables: + + def tableSection( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None, + actions: Modifier[HtmlElement]* + )( + table: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("px-4 sm:px-6 lg:px-8"), + div( + cls("sm:flex sm:items-center"), + div( + cls("sm:flex-auto"), + h1(cls("text-base font-semibold leading-6 text-gray-900"), title), + subtitle.map(st => p(cls("mt-2 text-sm text-gray-700"), st)) + ), + div(cls("mt-4 sm:ml-16 sm:mt-0 sm:flex-none"), actions) + ), + table + ) + + def tableContainer(table: ReactiveHtmlElement[html.Table]): HtmlElement = + div( + cls("mt-8 flow-root"), + div( + cls("-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"), + div( + cls("inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"), + table + ) + ) + ) + + def simpleTable(header: ReactiveHtmlElement[html.TableRow]*)( + body: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] = + table( + cls("min-w-full divide-y divide-gray-300"), + thead(header), + tbody(cls("divide-y divide-gray-200"), body) + ) + + def headerRow( + mods: HtmlMod* + )( + cells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] = + tr( + cells.zipWithIndex.map((c, i) => + if i == 0 then c.amend(cls("py-3.5 pl-4 pr-3 sm:pl-0")) + else if i == cells.length - 1 then + c.amend(cls("py-3.5 pr-4 pl-3 sm:pr-0")) + else c.amend(cls("py-3.5 px-3")) + ) + ) + + def dataRow( + mods: HtmlMod* + )( + cells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] = + tr( + mods, + cells.zipWithIndex.map((c, i) => + if i == 0 then c.amend(cls("py-4 pl-4 pr-3 sm:pl-0")) + else if i == cells.length - 1 then + c.amend(cls("py-4 pr-4 pl-3 sm:pr-0")) + else c.amend(cls("py-4 px-3")) + ) + ) + + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] = + th( + cls("text-left text-sm font-semibold text-gray-900"), + content + ) + + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] = + td( + cls("text-sm text-gray-500"), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUICatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUICatalogue.scala new file mode 100644 index 0000000..57ad709 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUICatalogue.scala @@ -0,0 +1,25 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +trait TailwindUICatalogueModule + extends BadgeComponentsModule + with ButtonComponentsModule + with HeadingsComponentsModule + with IconsModule + with FormComponentsModule + with LayoutModule + with ListComponentsModule + with ModalComponentsModule + with PageComponentsModule + with TableComponentsModule + with DetailComponentsModule + with ContainerComponentsModule + with PanelComponentsModule + with ListContainerComponentsModule + with AppShellComponentsModule + with UserMenuComponentsModule + with ZeroComponentsModule + with AlertComponentsModule + with ComboboxModule + with BreadcrumbsModule + +object TailwindUICatalogue extends TailwindUICatalogueModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormBuilderModule.scala new file mode 100644 index 0000000..81eb351 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormBuilderModule.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormMessagesResolver +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.forms.FormUIFactory +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait TailwindUIFormBuilderModule extends FormBuilderModule + +object TailwindUIFormBuilderModule: + given (using ctx: ComponentContext[_]): FormBuilderContext with + override def formMessagesResolver: FormMessagesResolver = + summon[FormMessagesResolver] + override def formUIFactory: FormUIFactory = TailwindUIFormUIFactory diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormUIFactory.scala new file mode 100644 index 0000000..3eea891 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormUIFactory.scala @@ -0,0 +1,161 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import works.iterative.ui.components.laminar.forms.FormUIFactory +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import TailwindUICatalogue.{forms, buttons, icons} +import org.scalajs.dom.html +import io.laminext.syntax.core.* +import com.raquo.laminar.tags.HtmlTag + +trait TailwindUIFormUIFactory extends FormUIFactory: + override def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] = + forms.form(mods*)(sections*)(actions*) + + override def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): L.HtmlElement = + forms.section(title, subtitle)(content*) + + override def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] = + forms.label(labelText, forId, required)(mods*) + + override def field(label: HtmlMod)(content: HtmlMod*): HtmlElement = + forms.field(label)(content*) + + override def submit(text: HtmlMod)(mods: HtmlMod*): HtmlElement = + buttons.primaryButton(text, None, None, "submit")(mods) + + override def cancel(text: HtmlMod)(mods: HtmlMod*): HtmlElement = + buttons.secondaryButton(text, None, None, "button")(cls("mr-3"), mods) + + override def validationError(text: HtmlMod): HtmlElement = + forms.validationError(text) + + override def errorTextMods: HtmlMod = forms.errorTextMods + + override def fieldHelp(text: HtmlMod): HtmlElement = + forms.fieldHelp(text) + + override def helpTextMods: HtmlMod = forms.helpTextMods + + private def renderInput[Ref <: org.scalajs.dom.HTMLElement]( + as: HtmlTag[Ref], + inError: Signal[Boolean], + mods: HtmlMod, + amendInput: ReactiveHtmlElement[Ref] => ReactiveHtmlElement[Ref] + ): Div = + div( + cls("relative w-full sm:max-w-xs rounded-md shadow-sm"), + amendInput( + as( + cls.toggle( + "text-red-900 ring-red-300 placeholder:text-red-300 focus:ring-red-500" + ) <-- inError, + cls.toggle( + "text-gray-900 ring-gray-300 placeholder:text-gray-400 focus:ring-indigo-600" + ) <-- inError.not, + cls( + "block w-full rounded-md border-0 py-1.5 shadow-sm ring-1 ring-inset focus:ring-2 focus:ring-inset sm:max-w-xs sm:text-sm sm:leading-6" + ), + mods + ) + ), + inError.childWhenTrue( + div( + cls( + "pointer-events-none absolute inset-y-0 right-0 pr-3 flex items-center" + ), + icons.`exclamation-circle-solid`(svg.cls("h-5 w-5 text-red-500")) + ) + ) + ) + + override def textarea( + inError: Signal[Boolean], + amendInput: TextArea => TextArea = identity + )(mods: HtmlMod*): HtmlElement = + renderInput(L.textArea, inError, mods, amendInput) + + override def input( + inError: Signal[Boolean], + amendInput: Input => Input = identity + )( + mods: HtmlMod* + ): HtmlElement = + renderInput(L.input, inError, mods, amendInput) + + override def select( + inError: Signal[Boolean], + amendInput: Select => Select = identity + )(mods: HtmlMod*): HtmlElement = + renderInput(L.select, inError, mods, amendInput) + override object combobox extends FormUIFactory.ComboboxComponents: + import works.iterative.ui.components.laminar.tailwind.ui.TailwindUICatalogue.combobox.simple + override def container( + inError: Signal[Boolean], + amendInput: Input => Input = identity + )(mods: HtmlMod*): HtmlElement = + simple.container(cls("sm:max-w-xs"), input(inError, amendInput)(), mods) + + override def button(mods: HtmlMod*): HtmlElement = simple.button(mods) + + override def options(mods: HtmlMod*): HtmlElement = simple.options(mods) + + override def option( + label: String, + isActive: Signal[Boolean], + isSelected: Signal[Boolean] + )(mods: HtmlMod*): HtmlElement = + simple.option(isActive, isSelected)( + simple.optionValue(label), + isSelected.childWhenTrue(simple.checkmark(isActive)) + ) + + override def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement = + val selectedFile: Var[Option[String]] = Var(None) + div( + cls := "mt-4 sm:mt-0 sm:flex-none", + L.label( + cls("block w-full"), + div( + buttonMods, + cls("cursor-pointer"), + buttons.sharedButtonMod, + buttons.secondaryButtonMod, + child <-- selectedFile.signal + .map(_.isDefined) + .switch( + icons.`paper-clip-solid`(svg.cls("w-6 h-6 mr-2")), + icons.upload(svg.cls("w-6 h-6 mr-2")) + ), + span(child.text <-- selectedFile.signal.map(_.getOrElse(title))) + ), + L.input( + cls("hidden"), + tpe("file"), + inputMods, + inContext(thisNode => + onInput + .mapTo( + thisNode.ref.files.headOption.map(_.name) + ) --> selectedFile.writer + ) + ) + ) + ) + +object TailwindUIFormUIFactory extends TailwindUIFormUIFactory diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/UserMenuComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/UserMenuComponentsModule.scala new file mode 100644 index 0000000..71d382b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/UserMenuComponentsModule.scala @@ -0,0 +1,55 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait UserMenuComponentsModule: + object userMenu: + def navBarItem(button: HtmlElement, popup: HtmlElement): HtmlElement = + div( + cls("relative ml-3"), + div(button), + popup + ) + + def userName(name: String): HtmlElement = + span(cls("text-white"), name) + + def avatar(href: String): HtmlElement = + img( + src(href), + cls("h-8 w-8 rounded-full") + ) + + def menuButton(userDetails: Node*): HtmlElement = + button( + tpe("button"), + cls( + "flex max-w-xs items-center rounded-full bg-indigo-600 text-sm focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-indigo-600" + ), + idAttr("user-menu-button"), + aria.hasPopup(true), + userDetails, + span(cls("sr-only"), "Open user menu") + ) + + def popup(menuItems: HtmlElement*): HtmlElement = + div( + cls( + "absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none" + ), + role("menu"), + aria.orientation("vertical"), + aria.labelledBy("user-menu-button"), + tabIndex(-1), + menuItems + ) + + def menuItem(id: String, label: Node): HtmlElement = + a( + cls("block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"), + href("#"), + role("menuitem"), + tabIndex(-1), + idAttr(s"user-menu-item-${id}"), + label + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ZeroComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ZeroComponentsModule.scala new file mode 100644 index 0000000..4278d55 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ZeroComponentsModule.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.LaminarExtensions.* +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage +import works.iterative.core.MessageId + +trait ZeroComponentsModule: + object zero: + def message(id: MessageId)(using ComponentContext[?]): HtmlElement = + message(UserMessage(id), UserMessage(s"${id}.description")) + + def message(headerMessage: UserMessage, descriptionElement: HtmlMod)(using + ComponentContext[?] + ): HtmlElement = + message(headerMessage.asElement, descriptionElement) + + def message(headerMessage: UserMessage, descriptionMessage: UserMessage)( + using ComponentContext[?] + ): HtmlElement = + message( + headerMessage.asElement, + p(cls("mt-1 text-sm text-gray-500"), descriptionMessage.asElement) + ) + + def message( + headerElement: HtmlMod, + descriptionElement: HtmlMod + ): HtmlElement = + div( + cls("text-center"), + h3( + cls("mt-2 text-sm font-semibold text-gray-900"), + headerElement + ), + descriptionElement + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala new file mode 100644 index 0000000..d2bd069 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala @@ -0,0 +1,160 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind +import works.iterative.ui.components.laminar.tailwind.color.ColorWeight + +trait AlertComponentsModule: + self: IconsModule => + object alerts: + // TODO: use context functions and builder pattern to build the alerts + def alert( + message: HtmlMod, + icon: SvgElement, + color: ColorKind, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + div( + cls := "rounded-md p-4", + color(50).bg, + div( + cls := "flex", + icon, + div( + cls := "ml-3", + title.map(t => + h3( + cls := "text-sm font-medium", + color(800).text, + t + ) + ), + div( + cls := "text-sm", + title.map(_ => cls("mt-2")), + color(700).text, + message + ), + actions.map { act => + div( + cls := "mt-4", + act(color) + ) + } + ), + mods(color) + ) + ) + + def warning( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-warning`(), + ColorKind.yellow, + title = title, + actions = actions, + mods = mods + ) + + def error( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-error`(), + ColorKind.red, + title = title, + actions = actions, + mods = mods + ) + + def success( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-success`(), + ColorKind.green, + title = title, + actions = actions, + mods = mods + ) + + def info( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-info`(), + ColorKind.blue, + title = title, + actions = actions, + mods = mods + ) + + def buttons(buttons: (ColorKind => HtmlMod)*): ColorKind => HtmlMod = + color => + div( + cls("-mx-2 -my-1.5 flex"), + buttons.map(_(color)) + ) + + def button(title: String)(mods: HtmlMod*): ColorKind => HtmlMod = color => + L.button( + tpe("button"), + cls( + "first:ml-0 ml-3 rounded-md px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(800).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + title, + mods + ) + + def closeMod(mods: HtmlMod): ColorKind => HtmlMod = color => + val closeIcon: SvgElement = + import svg.* + svg( + cls := "h-5 w-5", + viewBox := "0 0 20 20", + fill := "currentColor", + path( + d := "M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" + ) + ) + div( + cls("ml-auto pl-3"), + L.button( + cls( + "-mx-1.5 -my-1.5 inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(500).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + mods, + aria.hidden := true, + closeIcon + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala new file mode 100644 index 0000000..fa374fe --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala @@ -0,0 +1,52 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait AppShellComponentsModule: + object shell: + object stackedLight extends StackedAppShell: + override def navCls = cls("border-b border-gray-200 bg-white") + override def headerCls = emptyMod + override def pageWrapper(content: HtmlMod*): HtmlMod = + div(cls("py-10"), content) + + object stackedBranded extends StackedAppShell: + override def navCls = cls("bg-indigo-600") + override def headerCls = cls("bg-white shadown-sm") + override def pageWrapper(content: HtmlMod*): HtmlMod = content + +trait StackedAppShell: + protected def navCls: HtmlMod + protected def headerCls: HtmlMod + protected def pageWrapper(content: HtmlMod*): HtmlMod + + def apply(pageTitle: HtmlMod)(navbarItems: HtmlMod*)( + content: HtmlMod* + ): HtmlElement = + div( + cls("min-h-full"), + navTag( + navCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + div(cls("flex h-16 items-center justify-between"), navbarItems) + ) + ), + pageWrapper( + headerTag( + headerCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + h1( + cls( + "text-3xl font-bold leading-tight tracking-tight text-gray-900" + ), + pageTitle + ) + ) + ), + mainTag( + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala new file mode 100644 index 0000000..f6d7d7f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait BadgeComponentsModule: + + object badges: + def pill(name: String, color: ColorKind): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + name + ) + + def pill(name: String, color: Signal[ColorKind]): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + cls <-- color.map(_.apply(800).text.toCSS), + cls <-- color.map(_.apply(100).bg.toCSS), + name + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala new file mode 100644 index 0000000..03a2c9c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait BreadcrumbsModule: + self: IconsModule => + + object breadcrumbs: + def container(mods: HtmlMod*): HtmlElement = + navTag(cls("flex"), aria.label("Breadcrumb"), mods) + + def list(mods: HtmlMod*): HtmlElement = + ol(cls("flex items-center space-x-4"), role("list"), mods) + + def homeItem(mods: HtmlMod*): HtmlElement = + li( + div( + a( + href("#"), + cls("text-gray-400 hover:text-gray-500"), + icons.home(svg.cls("h-5 w-5 flex-shrink-0")), + span(cls("sr-only"), "Home"), + mods + ) + ) + ) + + def item(mods: HtmlMod*): HtmlElement = + li( + div( + cls("flex items-center"), + icons.`chevron-right-solid`( + svg.cls("h-5 w-5 flex-shrink-0 text-gray-400") + ), + a( + href("#"), + cls("ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala new file mode 100644 index 0000000..27f3cf2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala @@ -0,0 +1,102 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +trait ButtonComponentsModule: + + object buttons: + + private inline def srHelp(text: String): Modifier[HtmlElement] = + span(cls := "sr-only", text) + + val sharedButtonClasses = + "inline-flex justify-center py-2 px-4 border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + + val sharedButtonMod: HtmlMod = cls(sharedButtonClasses) + + val primaryButtonClasses = + "border-transparent text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300" + val primaryButtonMod: HtmlMod = cls(primaryButtonClasses) + + val secondaryButtonClasses = + "border-gray-300 text-gray-700 hover:bg-gray-50" + val secondaryButtonMod: HtmlMod = cls(secondaryButtonClasses) + + val neutralButtonClasses = + "border-gray-300 text-gray-700 bg-white hover:bg-gray-50" + val neutralButtonMod: HtmlMod = cls(neutralButtonClasses) + + val positiveButtonClasses = + "border-transparent text-white bg-green-600 hover:bg-green-700" + val positiveButtonMod: HtmlMod = cls(positiveButtonClasses) + + val negativeButtonClasses = + "border-transparent text-white bg-red-600 hover:bg-red-700" + val negativeButtonMod: HtmlMod = cls(negativeButtonClasses) + + def button( + text: Modifier[HtmlElement], + id: Option[String], + icon: Option[SvgElement] = None, + buttonType: String = "submit", + primary: Boolean = false + )(mods: HtmlMod*): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe(buttonType), + sharedButtonMod, + if primary then primaryButtonMod else secondaryButtonMod, + icon, + text, + mods + ) + + def primaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "submit" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, true)(mods*) + + def secondaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "button" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, false)(mods*) + + def inlineButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srText.map(srHelp(_)), + icon + ) + + def iconButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srText.map(srHelp(_)), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala new file mode 100644 index 0000000..ae4ab86 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala @@ -0,0 +1,56 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +import io.laminext.syntax.core.* + +trait ComboboxModule: + self: IconsModule => + + object combobox: + object simple: + def container(mods: HtmlMod*): Div = div(cls("relative mt-2"), mods) + + def button(mods: HtmlMod*): Button = + L.button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + mods + ) + + def options(mods: HtmlMod*) = + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + role := "listbox", + mods + ) + + def option(isActive: Signal[Boolean], isSelected: Signal[Boolean])( + mods: HtmlMod* + ) = + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- isActive, + cls.toggle("text-gray-900") <-- isActive.not, + cls.toggle("font-semibold") <-- isSelected, + mods + ) + + def optionValue(l: String): HtmlElement = + span(cls("block truncate"), l) + + def checkmark(isActive: Signal[Boolean]): HtmlElement = + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- isActive.not, + cls.toggle("text-white") <-- isActive, + icons.check(svg.cls("h-5 w-5")) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala new file mode 100644 index 0000000..f5a8873 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ContainerComponentsModule: + object container: + /** Full-width on mobile, constrained with padded content above */ + def default(content: HtmlMod*): HtmlElement = + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + + def padded(content: HtmlMod*): HtmlElement = + default(cls("px-4"), content) + + def narrow(content: HtmlMod*): HtmlElement = + padded( + div(cls("mx-auto max-w-3xl"), content) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala new file mode 100644 index 0000000..d601ab6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala @@ -0,0 +1,117 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.core.FileRef +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.laminar.* +import works.iterative.core.UserMessage + +trait DetailComponentsModule: + self: IconsModule => + object details: + def sectionHeader( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls( + "flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls := "px-4 sm:px-0", + h3( + cls := "text-base font-semibold leading-7 text-gray-900", + title + ), + subtitle.map(st => + p( + cls := "mt-1 max-w-2xl text-sm leading-6 text-gray-500", + st + ) + ) + ), + div(cls("flex-shrink-0"), actions) + ) + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + )(content: HtmlMod): HtmlElement = + div(sectionHeader(title, subtitle, actions), content) + + def fields(items: Node*): HtmlElement = + div( + dl( + cls := "divide-y divide-gray-100", + items + ) + ) + + def field( + title: Node, + content: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "px-2 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0", + dt( + cls := "text-sm font-medium leading-6 text-gray-900", + title + ), + dd( + cls := "mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0", + content + ) + ) + + def files( + fs: Seq[FileRef], + fileMods: Option[(FileRef, Int) => HtmlMod] = None + )( + mods: HtmlMod* + )(using ComponentContext[?]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-100 rounded-md border border-gray-200", + fs.zipWithIndex + .map((f, i) => + fileMods match + case Some(fm) => file(f)(fm(f, i)) + case _ => file(f)() + ), + mods + ) + + def file(f: FileRef)(mods: HtmlMod*)(using + ComponentContext[?] + ): HtmlElement = + li( + cls := "flex items-center justify-between py-4 pl-4 pr-5 text-sm leading-6", + div( + cls := "flex w-0 flex-1 items-center", + icons.`paper-clip-solid`(), + div( + cls := "ml-4 flex min-w-0 flex-1 gap-2", + span( + cls := "truncate font-medium", + f.name + ), + f.sizeString.map(size => + span(cls := "flex-shrink-0 text-gray-400", size) + ) + ) + ), + div( + cls := "ml-4 flex-shrink-0", + a( + href := f.url, + target := "_blank", + cls := "font-medium text-indigo-600 hover:text-indigo-500", + UserMessage("file.download").asString + ) + ), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala new file mode 100644 index 0000000..e9d7f1b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala @@ -0,0 +1,202 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html +import io.laminext.syntax.core.* + +trait FormComponentsModule: + self: IconsModule => + + object forms: + + val inputClasses = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls("space-y-6 sm:space-y-5"), + div( + h3(cls("text-lg leading-6 font-medium text-gray-900"), title), + subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) + ), + div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) + ) + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: Modifier[HtmlElement]* + ): ReactiveHtmlElement[html.Label] = + L.label( + cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), + forId.map(id => L.forId(id)), + labelText, + if required then sup(cls("text-gray-400"), "* povinné pole") + else emptyMod, + mods + ) + + def field( + label: Modifier[HtmlElement] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls( + "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5" + ), + label, + div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) + ) + + def field( + id: String, + labelText: String, + input: HtmlElement, + help: Option[String] + ): HtmlElement = + field( + label(labelText, Some(id))() + )( + input.amend(idAttr(id)), + help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) + ) + + def form(mods: Modifier[HtmlElement]*)( + sections: Modifier[HtmlElement]* + )(actions: Modifier[HtmlElement]*): ReactiveHtmlElement[html.Form] = + L.form( + cls("space-y-8 divide-y divide-gray-200"), + mods, + sections, + div( + cls("pt-5"), + div(cls("flex justify-end"), actions) + ) + ) + + def inlineForm( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + def errorTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-red-600") + + def validationError(text: Modifier[HtmlElement]): HtmlElement = + p(errorTextMods, text) + + def helpTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-gray-500") + + def fieldHelp(text: Modifier[HtmlElement]): HtmlElement = + p(helpTextMods, text) + + def inputField( + id: String, + labelText: String, + placeholderText: Option[String] = None, + inputType: String = "text", + helpText: Option[String] = None + ): HtmlElement = + field( + id, + labelText, + input(id, inputType, placeholderText)(), + helpText + ) + + def input( + id: String, + inputType: String = "text", + placeholderText: Option[String] = None + )(mods: HtmlMod*): HtmlElement = + L.input( + cls(inputClasses), + idAttr(id), + nameAttr(id), + placeholderText.map(placeholder(_)), + tpe(inputType), + mods + ) + + def comboBoxSimple( + options: List[(String, String)], + selectedInitially: Option[String] = None, + id: Option[String] = None, + name: Option[String] = None + ): HtmlElement = + val expanded = Var(false) + val selected = Var(selectedInitially) + div( + cls("relative mt-2"), + L.input( + cls( + "w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + id.map(idAttr(_)), + name.map(nameAttr(_)), + tpe("text"), + role("combobox"), + aria.controls("options"), + aria.expanded <-- expanded + ), + button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + onClick.preventDefault --> (_ => expanded.toggle()) + ), + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + cls.toggle("hidden") <-- expanded.signal.not, + id.map(i => idAttr(s"${i}-options")), + role := "listbox", + // Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation. + // Active: "text-white bg-indigo-600", Not Active: "text-gray-900" + for (((v, l), i) <- options.zipWithIndex) + yield + val active = Var(false) + val isSelected = selected.signal.map(_.contains(v)) + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- active.signal, + cls.toggle("text-gray-900") <-- active.signal.not, + cls.toggle("font-semibold") <-- isSelected, + id.map(cid => idAttr(s"${cid}-option-${i}")), + role := "option", + tabIndex := -1, + // Selected: "font-semibold" + span(cls("block truncate"), l), + // Checkmark, only display for selected option. + // Active: "text-white", Not Active: "text-indigo-600" + isSelected.childWhenTrue( + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- active.signal.not, + cls.toggle("text-white") <-- active.signal, + icons.check(svg.cls("h-5 w-5")) + ) + ), + onClick.preventDefault.mapTo( + v + ) --> selected.writer.contramapSome, + onMouseEnter --> (_ => active.set(true)), + onMouseLeave --> (_ => active.set(false)) + ) + // More items... + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala new file mode 100644 index 0000000..6a44cce --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait HeadingsComponentsModule: + object headings: + def section(title: Node, actions: HtmlElement*): HtmlElement = + div( + cls("border-b border-gray-200 pb-5"), + div( + cls("sm:flex sm:items-center sm:justify-between"), + h3(cls("text-base font-semibold leading-6 text-gray-900"), title), + div( + cls("mt-3 flex sm:ml-4 sm:mt-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) + + def sectionWithSubtitle( + title: Node, + subtitle: Node, + actions: HtmlElement* + ): HtmlElement = + div( + cls("border-b border-gray-200 bg-white px-4 py-5 sm:px-6"), + div( + cls( + "-ml-4 -mt-4 flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls("ml-4 mt-4"), + h3( + cls("text-base font-semibold leading-6 text-gray-900"), + title + ), + p( + cls("mt-1 text-sm text-gray-500"), + subtitle + ) + ), + div( + cls("ml-4 mt-4 flex-shrink-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala new file mode 100644 index 0000000..be16e48 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala @@ -0,0 +1,321 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.codecs +import works.iterative.ui.components.laminar.CustomAttrs + +trait IconsModule: + object icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + def upload(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 24 24"), + path(d := "M0 0h24v24H0z", fill("none")), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + + def home(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 20 20"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + clipRule := "evenodd", + fillRule := "evenodd", + d := "M9.293 2.293a1 1 0 011.414 0l7 7A1 1 0 0117 11h-1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-3a1 1 0 00-1-1H9a1 1 0 00-1 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-6H3a1 1 0 01-.707-1.707l7-7z" + ) + ) + + def `x-mark-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + def `arrow-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18") + ) + ) + + def `chevron-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M15.75 19.5L8.25 12l7.5-7.5") + ) + ) + + def `chevron-right-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + clipRule("evenodd"), + fillRule("evenodd"), + d( + "M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" + ) + ) + ) + + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) + + def `paper-clip-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + cls := "flex-shrink-0 text-gray-400", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M15.621 4.379a3 3 0 00-4.242 0l-7 7a3 3 0 004.241 4.243h.001l.497-.5a.75.75 0 011.064 1.057l-.498.501-.002.002a4.5 4.5 0 01-6.364-6.364l7-7a4.5 4.5 0 016.368 6.36l-3.455 3.553A2.625 2.625 0 119.52 9.52l3.45-3.451a.75.75 0 111.061 1.06l-3.45 3.451a1.125 1.125 0 001.587 1.595l3.454-3.553a3 3 0 000-4.242z", + clipRule := "evenodd" + ) + ) + + def `exclamation-circle-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-warning`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-yellow-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-error`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-red-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z", + clipRule := "evenodd" + ) + ) + + def `alert-success`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-green-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z", + clipRule := "evenodd" + ) + ) + + def `alert-info`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-blue-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z", + clipRule := "evenodd" + ) + ) + + def spinner(mods: SvgMod*): SvgElement = svg( + withDefault(mods, cls("h-4 w-4")), + svgAttr("role", codecs.StringAsIsCodec, None) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + def arrowPath(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + fill("none"), + stroke("currentColor"), + strokeWidth("1.5"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d( + "M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" + ) + ) + ) + + def chevronUpDown(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z", + clipRule := "evenodd" + ) + ) + + def check(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z", + clipRule := "evenodd" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala new file mode 100644 index 0000000..70c4a7c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* + +trait LayoutModule: + object layout: + def cardMod: HtmlMod = cls("bg-white shadow px-4 py-5 sm:rounded-lg sm:p-6") + def card(content: Modifier[HtmlElement]*): HtmlElement = + div(cardMod, content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala new file mode 100644 index 0000000..5b64ad7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala @@ -0,0 +1,88 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait ListComponentsModule: + self: BadgeComponentsModule => + + object list: + def label( + text: String, + color: ColorKind + ): HtmlElement = badges.pill(text, color) + + def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None, + contentMod: Modifier[HtmlElement] = emptyMod + ): LI = + li( + cls("group"), + div( + contentMod, + cls( + "relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" + ), + avatar.map(a => + div( + cls("flex-shrink-0"), + div( + cls( + "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" + ), + a + ) + ) + ), + div( + cls("flex-1 min-w-0"), + p( + cls("text-sm font-medium text-gray-900"), + title, + span(cls("float-right"), right) + ), + subtitle.map(st => + p( + cls("text-sm text-gray-500 truncate"), + st + ) + ) + ) + ) + ) + + def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = + ul( + cls("relative z-0 divide-y divide-gray-200"), + role("list"), + children + ) + + def listSection( + header: String, + list: HtmlElement + ): Div = + div( + cls("relative"), + div( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + list + ) + + def navigation(sections: Modifier[HtmlElement]): HtmlElement = + navTag( + cls("flex-1 min-h-0 overflow-y-auto"), + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala new file mode 100644 index 0000000..d4a2c56 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ListContainerComponentsModule: + object listContainer: + def simpleWithDividers(items: HtmlMod*) = + ul( + role("list"), + cls("divide-y divide-gray-200"), + items.map(li(cls("py-4"), _)) + ) + + def cardWithDividers(items: HtmlMod*) = + div( + cls("overflow-hidden rounded-md bg-white shadow"), + ul( + role := "list", + cls("divide-y divide-gray-200"), + items.map( + li( + cls("px-6 py-4"), + _ + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala new file mode 100644 index 0000000..684f0fb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait ModalComponentsModule: + object modal: + def modalDialog( + content: Signal[Option[HtmlElement]], + isOpen: Signal[Boolean], + close: Observer[Unit] + ): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + onClick.preventDefault.mapTo(()) --> close + ) + ) + + div( + cls.toggle("hidden") <-- isOpen.not.combineWithFn(content)( + _ || _.isEmpty + ), + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-visible rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + child.maybe <-- content + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala new file mode 100644 index 0000000..866b529 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala @@ -0,0 +1,48 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* + +trait PageComponentsModule: + + object page: + def container( + children: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8 overflow-y-auto"), + children + ) + + def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement = + div( + cls("p-8 bg-gray-100 h-full"), + header, + children + ) + + def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + div( + cls("pb-5 border-b border-gray-200"), + div(cls("float-right"), right), + h1( + cls("text-2xl leading-6 font-medium text-gray-900"), + title + ), + subtitle.map( + p( + cls("text-sm font-medium text-gray-500"), + _ + ) + ) + ) + + def clickable: Modifier[HtmlElement] = + cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala new file mode 100644 index 0000000..3603d98 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait PanelComponentsModule: + object panel: + def basicCard(content: HtmlMod*) = + div( + cls("bg-white overflow-hidden shadow rounded-lg"), + div(cls("px-4 py-5 sm:p-6"), content) + ) + + /** Card, edge-to-edge on mobile */ + def cardEdgeToEdgeOnMobile(content: HtmlMod*) = + div( + cls("bg-white overflow-hidden shadow sm:rounded-lg"), + div(cls("px-4 py-5 sm:p-6"), content) + ) + + def cardWithHeader(header: HtmlMod*)(content: HtmlMod*) = + div( + cls( + "divide-y divide-gray-200 overflow-hidden rounded-lg bg-white shadow" + ), + div( + cls("px-4 py-5 sm:px-6"), + header + ), + div( + cls("px-4 py-5 sm:p-6"), + content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TableComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TableComponentsModule.scala new file mode 100644 index 0000000..8f57c10 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TableComponentsModule.scala @@ -0,0 +1,95 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableComponentsModule: + + object tables: + + def tableSection( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None, + actions: Modifier[HtmlElement]* + )( + table: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("px-4 sm:px-6 lg:px-8"), + div( + cls("sm:flex sm:items-center"), + div( + cls("sm:flex-auto"), + h1(cls("text-base font-semibold leading-6 text-gray-900"), title), + subtitle.map(st => p(cls("mt-2 text-sm text-gray-700"), st)) + ), + div(cls("mt-4 sm:ml-16 sm:mt-0 sm:flex-none"), actions) + ), + table + ) + + def tableContainer(table: ReactiveHtmlElement[html.Table]): HtmlElement = + div( + cls("mt-8 flow-root"), + div( + cls("-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"), + div( + cls("inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"), + table + ) + ) + ) + + def simpleTable(header: ReactiveHtmlElement[html.TableRow]*)( + body: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] = + table( + cls("min-w-full divide-y divide-gray-300"), + thead(header), + tbody(cls("divide-y divide-gray-200"), body) + ) + + def headerRow( + mods: HtmlMod* + )( + cells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] = + tr( + cells.zipWithIndex.map((c, i) => + if i == 0 then c.amend(cls("py-3.5 pl-4 pr-3 sm:pl-0")) + else if i == cells.length - 1 then + c.amend(cls("py-3.5 pr-4 pl-3 sm:pr-0")) + else c.amend(cls("py-3.5 px-3")) + ) + ) + + def dataRow( + mods: HtmlMod* + )( + cells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] = + tr( + mods, + cells.zipWithIndex.map((c, i) => + if i == 0 then c.amend(cls("py-4 pl-4 pr-3 sm:pl-0")) + else if i == cells.length - 1 then + c.amend(cls("py-4 pr-4 pl-3 sm:pr-0")) + else c.amend(cls("py-4 px-3")) + ) + ) + + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] = + th( + cls("text-left text-sm font-semibold text-gray-900"), + content + ) + + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] = + td( + cls("text-sm text-gray-500"), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUICatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUICatalogue.scala new file mode 100644 index 0000000..57ad709 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUICatalogue.scala @@ -0,0 +1,25 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +trait TailwindUICatalogueModule + extends BadgeComponentsModule + with ButtonComponentsModule + with HeadingsComponentsModule + with IconsModule + with FormComponentsModule + with LayoutModule + with ListComponentsModule + with ModalComponentsModule + with PageComponentsModule + with TableComponentsModule + with DetailComponentsModule + with ContainerComponentsModule + with PanelComponentsModule + with ListContainerComponentsModule + with AppShellComponentsModule + with UserMenuComponentsModule + with ZeroComponentsModule + with AlertComponentsModule + with ComboboxModule + with BreadcrumbsModule + +object TailwindUICatalogue extends TailwindUICatalogueModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormBuilderModule.scala new file mode 100644 index 0000000..81eb351 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormBuilderModule.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormMessagesResolver +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.forms.FormUIFactory +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait TailwindUIFormBuilderModule extends FormBuilderModule + +object TailwindUIFormBuilderModule: + given (using ctx: ComponentContext[_]): FormBuilderContext with + override def formMessagesResolver: FormMessagesResolver = + summon[FormMessagesResolver] + override def formUIFactory: FormUIFactory = TailwindUIFormUIFactory diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormUIFactory.scala new file mode 100644 index 0000000..3eea891 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormUIFactory.scala @@ -0,0 +1,161 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import works.iterative.ui.components.laminar.forms.FormUIFactory +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import TailwindUICatalogue.{forms, buttons, icons} +import org.scalajs.dom.html +import io.laminext.syntax.core.* +import com.raquo.laminar.tags.HtmlTag + +trait TailwindUIFormUIFactory extends FormUIFactory: + override def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] = + forms.form(mods*)(sections*)(actions*) + + override def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): L.HtmlElement = + forms.section(title, subtitle)(content*) + + override def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] = + forms.label(labelText, forId, required)(mods*) + + override def field(label: HtmlMod)(content: HtmlMod*): HtmlElement = + forms.field(label)(content*) + + override def submit(text: HtmlMod)(mods: HtmlMod*): HtmlElement = + buttons.primaryButton(text, None, None, "submit")(mods) + + override def cancel(text: HtmlMod)(mods: HtmlMod*): HtmlElement = + buttons.secondaryButton(text, None, None, "button")(cls("mr-3"), mods) + + override def validationError(text: HtmlMod): HtmlElement = + forms.validationError(text) + + override def errorTextMods: HtmlMod = forms.errorTextMods + + override def fieldHelp(text: HtmlMod): HtmlElement = + forms.fieldHelp(text) + + override def helpTextMods: HtmlMod = forms.helpTextMods + + private def renderInput[Ref <: org.scalajs.dom.HTMLElement]( + as: HtmlTag[Ref], + inError: Signal[Boolean], + mods: HtmlMod, + amendInput: ReactiveHtmlElement[Ref] => ReactiveHtmlElement[Ref] + ): Div = + div( + cls("relative w-full sm:max-w-xs rounded-md shadow-sm"), + amendInput( + as( + cls.toggle( + "text-red-900 ring-red-300 placeholder:text-red-300 focus:ring-red-500" + ) <-- inError, + cls.toggle( + "text-gray-900 ring-gray-300 placeholder:text-gray-400 focus:ring-indigo-600" + ) <-- inError.not, + cls( + "block w-full rounded-md border-0 py-1.5 shadow-sm ring-1 ring-inset focus:ring-2 focus:ring-inset sm:max-w-xs sm:text-sm sm:leading-6" + ), + mods + ) + ), + inError.childWhenTrue( + div( + cls( + "pointer-events-none absolute inset-y-0 right-0 pr-3 flex items-center" + ), + icons.`exclamation-circle-solid`(svg.cls("h-5 w-5 text-red-500")) + ) + ) + ) + + override def textarea( + inError: Signal[Boolean], + amendInput: TextArea => TextArea = identity + )(mods: HtmlMod*): HtmlElement = + renderInput(L.textArea, inError, mods, amendInput) + + override def input( + inError: Signal[Boolean], + amendInput: Input => Input = identity + )( + mods: HtmlMod* + ): HtmlElement = + renderInput(L.input, inError, mods, amendInput) + + override def select( + inError: Signal[Boolean], + amendInput: Select => Select = identity + )(mods: HtmlMod*): HtmlElement = + renderInput(L.select, inError, mods, amendInput) + override object combobox extends FormUIFactory.ComboboxComponents: + import works.iterative.ui.components.laminar.tailwind.ui.TailwindUICatalogue.combobox.simple + override def container( + inError: Signal[Boolean], + amendInput: Input => Input = identity + )(mods: HtmlMod*): HtmlElement = + simple.container(cls("sm:max-w-xs"), input(inError, amendInput)(), mods) + + override def button(mods: HtmlMod*): HtmlElement = simple.button(mods) + + override def options(mods: HtmlMod*): HtmlElement = simple.options(mods) + + override def option( + label: String, + isActive: Signal[Boolean], + isSelected: Signal[Boolean] + )(mods: HtmlMod*): HtmlElement = + simple.option(isActive, isSelected)( + simple.optionValue(label), + isSelected.childWhenTrue(simple.checkmark(isActive)) + ) + + override def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement = + val selectedFile: Var[Option[String]] = Var(None) + div( + cls := "mt-4 sm:mt-0 sm:flex-none", + L.label( + cls("block w-full"), + div( + buttonMods, + cls("cursor-pointer"), + buttons.sharedButtonMod, + buttons.secondaryButtonMod, + child <-- selectedFile.signal + .map(_.isDefined) + .switch( + icons.`paper-clip-solid`(svg.cls("w-6 h-6 mr-2")), + icons.upload(svg.cls("w-6 h-6 mr-2")) + ), + span(child.text <-- selectedFile.signal.map(_.getOrElse(title))) + ), + L.input( + cls("hidden"), + tpe("file"), + inputMods, + inContext(thisNode => + onInput + .mapTo( + thisNode.ref.files.headOption.map(_.name) + ) --> selectedFile.writer + ) + ) + ) + ) + +object TailwindUIFormUIFactory extends TailwindUIFormUIFactory diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/UserMenuComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/UserMenuComponentsModule.scala new file mode 100644 index 0000000..71d382b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/UserMenuComponentsModule.scala @@ -0,0 +1,55 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait UserMenuComponentsModule: + object userMenu: + def navBarItem(button: HtmlElement, popup: HtmlElement): HtmlElement = + div( + cls("relative ml-3"), + div(button), + popup + ) + + def userName(name: String): HtmlElement = + span(cls("text-white"), name) + + def avatar(href: String): HtmlElement = + img( + src(href), + cls("h-8 w-8 rounded-full") + ) + + def menuButton(userDetails: Node*): HtmlElement = + button( + tpe("button"), + cls( + "flex max-w-xs items-center rounded-full bg-indigo-600 text-sm focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-indigo-600" + ), + idAttr("user-menu-button"), + aria.hasPopup(true), + userDetails, + span(cls("sr-only"), "Open user menu") + ) + + def popup(menuItems: HtmlElement*): HtmlElement = + div( + cls( + "absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none" + ), + role("menu"), + aria.orientation("vertical"), + aria.labelledBy("user-menu-button"), + tabIndex(-1), + menuItems + ) + + def menuItem(id: String, label: Node): HtmlElement = + a( + cls("block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"), + href("#"), + role("menuitem"), + tabIndex(-1), + idAttr(s"user-menu-item-${id}"), + label + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ZeroComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ZeroComponentsModule.scala new file mode 100644 index 0000000..4278d55 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ZeroComponentsModule.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.LaminarExtensions.* +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage +import works.iterative.core.MessageId + +trait ZeroComponentsModule: + object zero: + def message(id: MessageId)(using ComponentContext[?]): HtmlElement = + message(UserMessage(id), UserMessage(s"${id}.description")) + + def message(headerMessage: UserMessage, descriptionElement: HtmlMod)(using + ComponentContext[?] + ): HtmlElement = + message(headerMessage.asElement, descriptionElement) + + def message(headerMessage: UserMessage, descriptionMessage: UserMessage)( + using ComponentContext[?] + ): HtmlElement = + message( + headerMessage.asElement, + p(cls("mt-1 text-sm text-gray-500"), descriptionMessage.asElement) + ) + + def message( + headerElement: HtmlMod, + descriptionElement: HtmlMod + ): HtmlElement = + div( + cls("text-center"), + h3( + cls("mt-2 text-sm font-semibold text-gray-900"), + headerElement + ), + descriptionElement + ) diff --git a/ui/scenarios/src/main/scala/works/iterative/ui/FormsScenarioModule.scala b/ui/scenarios/src/main/scala/works/iterative/ui/FormsScenarioModule.scala new file mode 100644 index 0000000..0c9900a --- /dev/null +++ b/ui/scenarios/src/main/scala/works/iterative/ui/FormsScenarioModule.scala @@ -0,0 +1,66 @@ +package works.iterative.ui + +import com.raquo.laminar.api.L.* +import works.iterative.core.* +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.forms.* +import works.iterative.ui.components.laminar.tailwind.ui.{TailwindUICatalogue, TailwindUIFormBuilderModule, TailwindUIFormUIFactory} +import works.iterative.ui.scenarios.Scenario +import works.iterative.ui.scenarios.Scenario.Id + +object FormsScenarioModule + extends Scenario + with TailwindUIFormBuilderModule + with TailwindUIFormUIFactory: + + override val id: Id = "registrace" + + override val label: String = "Žádost o registraci" + + override def element(using ComponentContext[?]): HtmlElement = + val schema = + import FormSchema.* + val kontaktniOsobaSchema: FormSchema[KontaktniOsoba] = + ( + Control[UserName]("jmeno") *: Control[Email]( + "email" + ) *: FormSchema.Unit + ).map( + KontaktniOsoba.apply + )(k => (k.jmeno, k.email)) + + val adresaSchema: FormSchema[Adresa] = + ( + Control[PlainOneLine]("ulice") *: Control[PlainOneLine]( + "mesto" + ) *: Control[PSC]("psc") *: Control[Country]( + "country" + ) *: FormSchema.Unit + ).map( + Adresa.apply + )(a => (a.ulice, a.mesto, a.psc, a.country)) + + val zadatelSchema: FormSchema[Zadatel] = + ( + Control[PlainOneLine]("nazev") *: Control[IC]( + "ic" + ) *: adresaSchema *: FormSchema.Unit + ).map( + Zadatel.apply + )(z => (z.nazev, z.ic, z.adresa)) + + Section( + "zadost-o-registraci", + Section("zadatel", zadatelSchema) *: + Section("administrator", kontaktniOsobaSchema) *: + Section("pccr", kontaktniOsobaSchema) *: FormSchema.Unit + ) + .map( + ZadostORegistraci.apply + )(z => (z._1, z._2, z._3)) + + import TailwindUIFormBuilderModule.given + // Form schema + TailwindUICatalogue.layout.card( + buildForm[ZadostORegistraci](schema, Observer.empty).build(None).elements* + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala new file mode 100644 index 0000000..d2bd069 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala @@ -0,0 +1,160 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind +import works.iterative.ui.components.laminar.tailwind.color.ColorWeight + +trait AlertComponentsModule: + self: IconsModule => + object alerts: + // TODO: use context functions and builder pattern to build the alerts + def alert( + message: HtmlMod, + icon: SvgElement, + color: ColorKind, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + div( + cls := "rounded-md p-4", + color(50).bg, + div( + cls := "flex", + icon, + div( + cls := "ml-3", + title.map(t => + h3( + cls := "text-sm font-medium", + color(800).text, + t + ) + ), + div( + cls := "text-sm", + title.map(_ => cls("mt-2")), + color(700).text, + message + ), + actions.map { act => + div( + cls := "mt-4", + act(color) + ) + } + ), + mods(color) + ) + ) + + def warning( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-warning`(), + ColorKind.yellow, + title = title, + actions = actions, + mods = mods + ) + + def error( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-error`(), + ColorKind.red, + title = title, + actions = actions, + mods = mods + ) + + def success( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-success`(), + ColorKind.green, + title = title, + actions = actions, + mods = mods + ) + + def info( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-info`(), + ColorKind.blue, + title = title, + actions = actions, + mods = mods + ) + + def buttons(buttons: (ColorKind => HtmlMod)*): ColorKind => HtmlMod = + color => + div( + cls("-mx-2 -my-1.5 flex"), + buttons.map(_(color)) + ) + + def button(title: String)(mods: HtmlMod*): ColorKind => HtmlMod = color => + L.button( + tpe("button"), + cls( + "first:ml-0 ml-3 rounded-md px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(800).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + title, + mods + ) + + def closeMod(mods: HtmlMod): ColorKind => HtmlMod = color => + val closeIcon: SvgElement = + import svg.* + svg( + cls := "h-5 w-5", + viewBox := "0 0 20 20", + fill := "currentColor", + path( + d := "M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" + ) + ) + div( + cls("ml-auto pl-3"), + L.button( + cls( + "-mx-1.5 -my-1.5 inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(500).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + mods, + aria.hidden := true, + closeIcon + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala new file mode 100644 index 0000000..fa374fe --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala @@ -0,0 +1,52 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait AppShellComponentsModule: + object shell: + object stackedLight extends StackedAppShell: + override def navCls = cls("border-b border-gray-200 bg-white") + override def headerCls = emptyMod + override def pageWrapper(content: HtmlMod*): HtmlMod = + div(cls("py-10"), content) + + object stackedBranded extends StackedAppShell: + override def navCls = cls("bg-indigo-600") + override def headerCls = cls("bg-white shadown-sm") + override def pageWrapper(content: HtmlMod*): HtmlMod = content + +trait StackedAppShell: + protected def navCls: HtmlMod + protected def headerCls: HtmlMod + protected def pageWrapper(content: HtmlMod*): HtmlMod + + def apply(pageTitle: HtmlMod)(navbarItems: HtmlMod*)( + content: HtmlMod* + ): HtmlElement = + div( + cls("min-h-full"), + navTag( + navCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + div(cls("flex h-16 items-center justify-between"), navbarItems) + ) + ), + pageWrapper( + headerTag( + headerCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + h1( + cls( + "text-3xl font-bold leading-tight tracking-tight text-gray-900" + ), + pageTitle + ) + ) + ), + mainTag( + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala new file mode 100644 index 0000000..f6d7d7f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait BadgeComponentsModule: + + object badges: + def pill(name: String, color: ColorKind): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + name + ) + + def pill(name: String, color: Signal[ColorKind]): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + cls <-- color.map(_.apply(800).text.toCSS), + cls <-- color.map(_.apply(100).bg.toCSS), + name + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala new file mode 100644 index 0000000..03a2c9c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait BreadcrumbsModule: + self: IconsModule => + + object breadcrumbs: + def container(mods: HtmlMod*): HtmlElement = + navTag(cls("flex"), aria.label("Breadcrumb"), mods) + + def list(mods: HtmlMod*): HtmlElement = + ol(cls("flex items-center space-x-4"), role("list"), mods) + + def homeItem(mods: HtmlMod*): HtmlElement = + li( + div( + a( + href("#"), + cls("text-gray-400 hover:text-gray-500"), + icons.home(svg.cls("h-5 w-5 flex-shrink-0")), + span(cls("sr-only"), "Home"), + mods + ) + ) + ) + + def item(mods: HtmlMod*): HtmlElement = + li( + div( + cls("flex items-center"), + icons.`chevron-right-solid`( + svg.cls("h-5 w-5 flex-shrink-0 text-gray-400") + ), + a( + href("#"), + cls("ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala new file mode 100644 index 0000000..27f3cf2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala @@ -0,0 +1,102 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +trait ButtonComponentsModule: + + object buttons: + + private inline def srHelp(text: String): Modifier[HtmlElement] = + span(cls := "sr-only", text) + + val sharedButtonClasses = + "inline-flex justify-center py-2 px-4 border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + + val sharedButtonMod: HtmlMod = cls(sharedButtonClasses) + + val primaryButtonClasses = + "border-transparent text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300" + val primaryButtonMod: HtmlMod = cls(primaryButtonClasses) + + val secondaryButtonClasses = + "border-gray-300 text-gray-700 hover:bg-gray-50" + val secondaryButtonMod: HtmlMod = cls(secondaryButtonClasses) + + val neutralButtonClasses = + "border-gray-300 text-gray-700 bg-white hover:bg-gray-50" + val neutralButtonMod: HtmlMod = cls(neutralButtonClasses) + + val positiveButtonClasses = + "border-transparent text-white bg-green-600 hover:bg-green-700" + val positiveButtonMod: HtmlMod = cls(positiveButtonClasses) + + val negativeButtonClasses = + "border-transparent text-white bg-red-600 hover:bg-red-700" + val negativeButtonMod: HtmlMod = cls(negativeButtonClasses) + + def button( + text: Modifier[HtmlElement], + id: Option[String], + icon: Option[SvgElement] = None, + buttonType: String = "submit", + primary: Boolean = false + )(mods: HtmlMod*): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe(buttonType), + sharedButtonMod, + if primary then primaryButtonMod else secondaryButtonMod, + icon, + text, + mods + ) + + def primaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "submit" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, true)(mods*) + + def secondaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "button" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, false)(mods*) + + def inlineButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srText.map(srHelp(_)), + icon + ) + + def iconButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srText.map(srHelp(_)), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala new file mode 100644 index 0000000..ae4ab86 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala @@ -0,0 +1,56 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +import io.laminext.syntax.core.* + +trait ComboboxModule: + self: IconsModule => + + object combobox: + object simple: + def container(mods: HtmlMod*): Div = div(cls("relative mt-2"), mods) + + def button(mods: HtmlMod*): Button = + L.button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + mods + ) + + def options(mods: HtmlMod*) = + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + role := "listbox", + mods + ) + + def option(isActive: Signal[Boolean], isSelected: Signal[Boolean])( + mods: HtmlMod* + ) = + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- isActive, + cls.toggle("text-gray-900") <-- isActive.not, + cls.toggle("font-semibold") <-- isSelected, + mods + ) + + def optionValue(l: String): HtmlElement = + span(cls("block truncate"), l) + + def checkmark(isActive: Signal[Boolean]): HtmlElement = + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- isActive.not, + cls.toggle("text-white") <-- isActive, + icons.check(svg.cls("h-5 w-5")) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala new file mode 100644 index 0000000..f5a8873 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ContainerComponentsModule: + object container: + /** Full-width on mobile, constrained with padded content above */ + def default(content: HtmlMod*): HtmlElement = + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + + def padded(content: HtmlMod*): HtmlElement = + default(cls("px-4"), content) + + def narrow(content: HtmlMod*): HtmlElement = + padded( + div(cls("mx-auto max-w-3xl"), content) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala new file mode 100644 index 0000000..d601ab6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala @@ -0,0 +1,117 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.core.FileRef +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.laminar.* +import works.iterative.core.UserMessage + +trait DetailComponentsModule: + self: IconsModule => + object details: + def sectionHeader( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls( + "flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls := "px-4 sm:px-0", + h3( + cls := "text-base font-semibold leading-7 text-gray-900", + title + ), + subtitle.map(st => + p( + cls := "mt-1 max-w-2xl text-sm leading-6 text-gray-500", + st + ) + ) + ), + div(cls("flex-shrink-0"), actions) + ) + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + )(content: HtmlMod): HtmlElement = + div(sectionHeader(title, subtitle, actions), content) + + def fields(items: Node*): HtmlElement = + div( + dl( + cls := "divide-y divide-gray-100", + items + ) + ) + + def field( + title: Node, + content: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "px-2 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0", + dt( + cls := "text-sm font-medium leading-6 text-gray-900", + title + ), + dd( + cls := "mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0", + content + ) + ) + + def files( + fs: Seq[FileRef], + fileMods: Option[(FileRef, Int) => HtmlMod] = None + )( + mods: HtmlMod* + )(using ComponentContext[?]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-100 rounded-md border border-gray-200", + fs.zipWithIndex + .map((f, i) => + fileMods match + case Some(fm) => file(f)(fm(f, i)) + case _ => file(f)() + ), + mods + ) + + def file(f: FileRef)(mods: HtmlMod*)(using + ComponentContext[?] + ): HtmlElement = + li( + cls := "flex items-center justify-between py-4 pl-4 pr-5 text-sm leading-6", + div( + cls := "flex w-0 flex-1 items-center", + icons.`paper-clip-solid`(), + div( + cls := "ml-4 flex min-w-0 flex-1 gap-2", + span( + cls := "truncate font-medium", + f.name + ), + f.sizeString.map(size => + span(cls := "flex-shrink-0 text-gray-400", size) + ) + ) + ), + div( + cls := "ml-4 flex-shrink-0", + a( + href := f.url, + target := "_blank", + cls := "font-medium text-indigo-600 hover:text-indigo-500", + UserMessage("file.download").asString + ) + ), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala new file mode 100644 index 0000000..e9d7f1b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala @@ -0,0 +1,202 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html +import io.laminext.syntax.core.* + +trait FormComponentsModule: + self: IconsModule => + + object forms: + + val inputClasses = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls("space-y-6 sm:space-y-5"), + div( + h3(cls("text-lg leading-6 font-medium text-gray-900"), title), + subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) + ), + div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) + ) + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: Modifier[HtmlElement]* + ): ReactiveHtmlElement[html.Label] = + L.label( + cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), + forId.map(id => L.forId(id)), + labelText, + if required then sup(cls("text-gray-400"), "* povinné pole") + else emptyMod, + mods + ) + + def field( + label: Modifier[HtmlElement] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls( + "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5" + ), + label, + div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) + ) + + def field( + id: String, + labelText: String, + input: HtmlElement, + help: Option[String] + ): HtmlElement = + field( + label(labelText, Some(id))() + )( + input.amend(idAttr(id)), + help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) + ) + + def form(mods: Modifier[HtmlElement]*)( + sections: Modifier[HtmlElement]* + )(actions: Modifier[HtmlElement]*): ReactiveHtmlElement[html.Form] = + L.form( + cls("space-y-8 divide-y divide-gray-200"), + mods, + sections, + div( + cls("pt-5"), + div(cls("flex justify-end"), actions) + ) + ) + + def inlineForm( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + def errorTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-red-600") + + def validationError(text: Modifier[HtmlElement]): HtmlElement = + p(errorTextMods, text) + + def helpTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-gray-500") + + def fieldHelp(text: Modifier[HtmlElement]): HtmlElement = + p(helpTextMods, text) + + def inputField( + id: String, + labelText: String, + placeholderText: Option[String] = None, + inputType: String = "text", + helpText: Option[String] = None + ): HtmlElement = + field( + id, + labelText, + input(id, inputType, placeholderText)(), + helpText + ) + + def input( + id: String, + inputType: String = "text", + placeholderText: Option[String] = None + )(mods: HtmlMod*): HtmlElement = + L.input( + cls(inputClasses), + idAttr(id), + nameAttr(id), + placeholderText.map(placeholder(_)), + tpe(inputType), + mods + ) + + def comboBoxSimple( + options: List[(String, String)], + selectedInitially: Option[String] = None, + id: Option[String] = None, + name: Option[String] = None + ): HtmlElement = + val expanded = Var(false) + val selected = Var(selectedInitially) + div( + cls("relative mt-2"), + L.input( + cls( + "w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + id.map(idAttr(_)), + name.map(nameAttr(_)), + tpe("text"), + role("combobox"), + aria.controls("options"), + aria.expanded <-- expanded + ), + button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + onClick.preventDefault --> (_ => expanded.toggle()) + ), + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + cls.toggle("hidden") <-- expanded.signal.not, + id.map(i => idAttr(s"${i}-options")), + role := "listbox", + // Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation. + // Active: "text-white bg-indigo-600", Not Active: "text-gray-900" + for (((v, l), i) <- options.zipWithIndex) + yield + val active = Var(false) + val isSelected = selected.signal.map(_.contains(v)) + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- active.signal, + cls.toggle("text-gray-900") <-- active.signal.not, + cls.toggle("font-semibold") <-- isSelected, + id.map(cid => idAttr(s"${cid}-option-${i}")), + role := "option", + tabIndex := -1, + // Selected: "font-semibold" + span(cls("block truncate"), l), + // Checkmark, only display for selected option. + // Active: "text-white", Not Active: "text-indigo-600" + isSelected.childWhenTrue( + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- active.signal.not, + cls.toggle("text-white") <-- active.signal, + icons.check(svg.cls("h-5 w-5")) + ) + ), + onClick.preventDefault.mapTo( + v + ) --> selected.writer.contramapSome, + onMouseEnter --> (_ => active.set(true)), + onMouseLeave --> (_ => active.set(false)) + ) + // More items... + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala new file mode 100644 index 0000000..6a44cce --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait HeadingsComponentsModule: + object headings: + def section(title: Node, actions: HtmlElement*): HtmlElement = + div( + cls("border-b border-gray-200 pb-5"), + div( + cls("sm:flex sm:items-center sm:justify-between"), + h3(cls("text-base font-semibold leading-6 text-gray-900"), title), + div( + cls("mt-3 flex sm:ml-4 sm:mt-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) + + def sectionWithSubtitle( + title: Node, + subtitle: Node, + actions: HtmlElement* + ): HtmlElement = + div( + cls("border-b border-gray-200 bg-white px-4 py-5 sm:px-6"), + div( + cls( + "-ml-4 -mt-4 flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls("ml-4 mt-4"), + h3( + cls("text-base font-semibold leading-6 text-gray-900"), + title + ), + p( + cls("mt-1 text-sm text-gray-500"), + subtitle + ) + ), + div( + cls("ml-4 mt-4 flex-shrink-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala new file mode 100644 index 0000000..be16e48 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala @@ -0,0 +1,321 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.codecs +import works.iterative.ui.components.laminar.CustomAttrs + +trait IconsModule: + object icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + def upload(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 24 24"), + path(d := "M0 0h24v24H0z", fill("none")), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + + def home(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 20 20"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + clipRule := "evenodd", + fillRule := "evenodd", + d := "M9.293 2.293a1 1 0 011.414 0l7 7A1 1 0 0117 11h-1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-3a1 1 0 00-1-1H9a1 1 0 00-1 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-6H3a1 1 0 01-.707-1.707l7-7z" + ) + ) + + def `x-mark-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + def `arrow-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18") + ) + ) + + def `chevron-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M15.75 19.5L8.25 12l7.5-7.5") + ) + ) + + def `chevron-right-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + clipRule("evenodd"), + fillRule("evenodd"), + d( + "M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" + ) + ) + ) + + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) + + def `paper-clip-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + cls := "flex-shrink-0 text-gray-400", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M15.621 4.379a3 3 0 00-4.242 0l-7 7a3 3 0 004.241 4.243h.001l.497-.5a.75.75 0 011.064 1.057l-.498.501-.002.002a4.5 4.5 0 01-6.364-6.364l7-7a4.5 4.5 0 016.368 6.36l-3.455 3.553A2.625 2.625 0 119.52 9.52l3.45-3.451a.75.75 0 111.061 1.06l-3.45 3.451a1.125 1.125 0 001.587 1.595l3.454-3.553a3 3 0 000-4.242z", + clipRule := "evenodd" + ) + ) + + def `exclamation-circle-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-warning`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-yellow-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-error`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-red-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z", + clipRule := "evenodd" + ) + ) + + def `alert-success`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-green-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z", + clipRule := "evenodd" + ) + ) + + def `alert-info`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-blue-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z", + clipRule := "evenodd" + ) + ) + + def spinner(mods: SvgMod*): SvgElement = svg( + withDefault(mods, cls("h-4 w-4")), + svgAttr("role", codecs.StringAsIsCodec, None) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + def arrowPath(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + fill("none"), + stroke("currentColor"), + strokeWidth("1.5"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d( + "M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" + ) + ) + ) + + def chevronUpDown(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z", + clipRule := "evenodd" + ) + ) + + def check(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z", + clipRule := "evenodd" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala new file mode 100644 index 0000000..70c4a7c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* + +trait LayoutModule: + object layout: + def cardMod: HtmlMod = cls("bg-white shadow px-4 py-5 sm:rounded-lg sm:p-6") + def card(content: Modifier[HtmlElement]*): HtmlElement = + div(cardMod, content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala new file mode 100644 index 0000000..5b64ad7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala @@ -0,0 +1,88 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait ListComponentsModule: + self: BadgeComponentsModule => + + object list: + def label( + text: String, + color: ColorKind + ): HtmlElement = badges.pill(text, color) + + def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None, + contentMod: Modifier[HtmlElement] = emptyMod + ): LI = + li( + cls("group"), + div( + contentMod, + cls( + "relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" + ), + avatar.map(a => + div( + cls("flex-shrink-0"), + div( + cls( + "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" + ), + a + ) + ) + ), + div( + cls("flex-1 min-w-0"), + p( + cls("text-sm font-medium text-gray-900"), + title, + span(cls("float-right"), right) + ), + subtitle.map(st => + p( + cls("text-sm text-gray-500 truncate"), + st + ) + ) + ) + ) + ) + + def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = + ul( + cls("relative z-0 divide-y divide-gray-200"), + role("list"), + children + ) + + def listSection( + header: String, + list: HtmlElement + ): Div = + div( + cls("relative"), + div( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + list + ) + + def navigation(sections: Modifier[HtmlElement]): HtmlElement = + navTag( + cls("flex-1 min-h-0 overflow-y-auto"), + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala new file mode 100644 index 0000000..d4a2c56 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ListContainerComponentsModule: + object listContainer: + def simpleWithDividers(items: HtmlMod*) = + ul( + role("list"), + cls("divide-y divide-gray-200"), + items.map(li(cls("py-4"), _)) + ) + + def cardWithDividers(items: HtmlMod*) = + div( + cls("overflow-hidden rounded-md bg-white shadow"), + ul( + role := "list", + cls("divide-y divide-gray-200"), + items.map( + li( + cls("px-6 py-4"), + _ + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala new file mode 100644 index 0000000..684f0fb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait ModalComponentsModule: + object modal: + def modalDialog( + content: Signal[Option[HtmlElement]], + isOpen: Signal[Boolean], + close: Observer[Unit] + ): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + onClick.preventDefault.mapTo(()) --> close + ) + ) + + div( + cls.toggle("hidden") <-- isOpen.not.combineWithFn(content)( + _ || _.isEmpty + ), + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-visible rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + child.maybe <-- content + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala new file mode 100644 index 0000000..866b529 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala @@ -0,0 +1,48 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* + +trait PageComponentsModule: + + object page: + def container( + children: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8 overflow-y-auto"), + children + ) + + def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement = + div( + cls("p-8 bg-gray-100 h-full"), + header, + children + ) + + def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + div( + cls("pb-5 border-b border-gray-200"), + div(cls("float-right"), right), + h1( + cls("text-2xl leading-6 font-medium text-gray-900"), + title + ), + subtitle.map( + p( + cls("text-sm font-medium text-gray-500"), + _ + ) + ) + ) + + def clickable: Modifier[HtmlElement] = + cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala new file mode 100644 index 0000000..3603d98 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait PanelComponentsModule: + object panel: + def basicCard(content: HtmlMod*) = + div( + cls("bg-white overflow-hidden shadow rounded-lg"), + div(cls("px-4 py-5 sm:p-6"), content) + ) + + /** Card, edge-to-edge on mobile */ + def cardEdgeToEdgeOnMobile(content: HtmlMod*) = + div( + cls("bg-white overflow-hidden shadow sm:rounded-lg"), + div(cls("px-4 py-5 sm:p-6"), content) + ) + + def cardWithHeader(header: HtmlMod*)(content: HtmlMod*) = + div( + cls( + "divide-y divide-gray-200 overflow-hidden rounded-lg bg-white shadow" + ), + div( + cls("px-4 py-5 sm:px-6"), + header + ), + div( + cls("px-4 py-5 sm:p-6"), + content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TableComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TableComponentsModule.scala new file mode 100644 index 0000000..8f57c10 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TableComponentsModule.scala @@ -0,0 +1,95 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableComponentsModule: + + object tables: + + def tableSection( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None, + actions: Modifier[HtmlElement]* + )( + table: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("px-4 sm:px-6 lg:px-8"), + div( + cls("sm:flex sm:items-center"), + div( + cls("sm:flex-auto"), + h1(cls("text-base font-semibold leading-6 text-gray-900"), title), + subtitle.map(st => p(cls("mt-2 text-sm text-gray-700"), st)) + ), + div(cls("mt-4 sm:ml-16 sm:mt-0 sm:flex-none"), actions) + ), + table + ) + + def tableContainer(table: ReactiveHtmlElement[html.Table]): HtmlElement = + div( + cls("mt-8 flow-root"), + div( + cls("-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"), + div( + cls("inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"), + table + ) + ) + ) + + def simpleTable(header: ReactiveHtmlElement[html.TableRow]*)( + body: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] = + table( + cls("min-w-full divide-y divide-gray-300"), + thead(header), + tbody(cls("divide-y divide-gray-200"), body) + ) + + def headerRow( + mods: HtmlMod* + )( + cells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] = + tr( + cells.zipWithIndex.map((c, i) => + if i == 0 then c.amend(cls("py-3.5 pl-4 pr-3 sm:pl-0")) + else if i == cells.length - 1 then + c.amend(cls("py-3.5 pr-4 pl-3 sm:pr-0")) + else c.amend(cls("py-3.5 px-3")) + ) + ) + + def dataRow( + mods: HtmlMod* + )( + cells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] = + tr( + mods, + cells.zipWithIndex.map((c, i) => + if i == 0 then c.amend(cls("py-4 pl-4 pr-3 sm:pl-0")) + else if i == cells.length - 1 then + c.amend(cls("py-4 pr-4 pl-3 sm:pr-0")) + else c.amend(cls("py-4 px-3")) + ) + ) + + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] = + th( + cls("text-left text-sm font-semibold text-gray-900"), + content + ) + + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] = + td( + cls("text-sm text-gray-500"), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUICatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUICatalogue.scala new file mode 100644 index 0000000..57ad709 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUICatalogue.scala @@ -0,0 +1,25 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +trait TailwindUICatalogueModule + extends BadgeComponentsModule + with ButtonComponentsModule + with HeadingsComponentsModule + with IconsModule + with FormComponentsModule + with LayoutModule + with ListComponentsModule + with ModalComponentsModule + with PageComponentsModule + with TableComponentsModule + with DetailComponentsModule + with ContainerComponentsModule + with PanelComponentsModule + with ListContainerComponentsModule + with AppShellComponentsModule + with UserMenuComponentsModule + with ZeroComponentsModule + with AlertComponentsModule + with ComboboxModule + with BreadcrumbsModule + +object TailwindUICatalogue extends TailwindUICatalogueModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormBuilderModule.scala new file mode 100644 index 0000000..81eb351 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormBuilderModule.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormMessagesResolver +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.forms.FormUIFactory +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait TailwindUIFormBuilderModule extends FormBuilderModule + +object TailwindUIFormBuilderModule: + given (using ctx: ComponentContext[_]): FormBuilderContext with + override def formMessagesResolver: FormMessagesResolver = + summon[FormMessagesResolver] + override def formUIFactory: FormUIFactory = TailwindUIFormUIFactory diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormUIFactory.scala new file mode 100644 index 0000000..3eea891 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormUIFactory.scala @@ -0,0 +1,161 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import works.iterative.ui.components.laminar.forms.FormUIFactory +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import TailwindUICatalogue.{forms, buttons, icons} +import org.scalajs.dom.html +import io.laminext.syntax.core.* +import com.raquo.laminar.tags.HtmlTag + +trait TailwindUIFormUIFactory extends FormUIFactory: + override def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] = + forms.form(mods*)(sections*)(actions*) + + override def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): L.HtmlElement = + forms.section(title, subtitle)(content*) + + override def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] = + forms.label(labelText, forId, required)(mods*) + + override def field(label: HtmlMod)(content: HtmlMod*): HtmlElement = + forms.field(label)(content*) + + override def submit(text: HtmlMod)(mods: HtmlMod*): HtmlElement = + buttons.primaryButton(text, None, None, "submit")(mods) + + override def cancel(text: HtmlMod)(mods: HtmlMod*): HtmlElement = + buttons.secondaryButton(text, None, None, "button")(cls("mr-3"), mods) + + override def validationError(text: HtmlMod): HtmlElement = + forms.validationError(text) + + override def errorTextMods: HtmlMod = forms.errorTextMods + + override def fieldHelp(text: HtmlMod): HtmlElement = + forms.fieldHelp(text) + + override def helpTextMods: HtmlMod = forms.helpTextMods + + private def renderInput[Ref <: org.scalajs.dom.HTMLElement]( + as: HtmlTag[Ref], + inError: Signal[Boolean], + mods: HtmlMod, + amendInput: ReactiveHtmlElement[Ref] => ReactiveHtmlElement[Ref] + ): Div = + div( + cls("relative w-full sm:max-w-xs rounded-md shadow-sm"), + amendInput( + as( + cls.toggle( + "text-red-900 ring-red-300 placeholder:text-red-300 focus:ring-red-500" + ) <-- inError, + cls.toggle( + "text-gray-900 ring-gray-300 placeholder:text-gray-400 focus:ring-indigo-600" + ) <-- inError.not, + cls( + "block w-full rounded-md border-0 py-1.5 shadow-sm ring-1 ring-inset focus:ring-2 focus:ring-inset sm:max-w-xs sm:text-sm sm:leading-6" + ), + mods + ) + ), + inError.childWhenTrue( + div( + cls( + "pointer-events-none absolute inset-y-0 right-0 pr-3 flex items-center" + ), + icons.`exclamation-circle-solid`(svg.cls("h-5 w-5 text-red-500")) + ) + ) + ) + + override def textarea( + inError: Signal[Boolean], + amendInput: TextArea => TextArea = identity + )(mods: HtmlMod*): HtmlElement = + renderInput(L.textArea, inError, mods, amendInput) + + override def input( + inError: Signal[Boolean], + amendInput: Input => Input = identity + )( + mods: HtmlMod* + ): HtmlElement = + renderInput(L.input, inError, mods, amendInput) + + override def select( + inError: Signal[Boolean], + amendInput: Select => Select = identity + )(mods: HtmlMod*): HtmlElement = + renderInput(L.select, inError, mods, amendInput) + override object combobox extends FormUIFactory.ComboboxComponents: + import works.iterative.ui.components.laminar.tailwind.ui.TailwindUICatalogue.combobox.simple + override def container( + inError: Signal[Boolean], + amendInput: Input => Input = identity + )(mods: HtmlMod*): HtmlElement = + simple.container(cls("sm:max-w-xs"), input(inError, amendInput)(), mods) + + override def button(mods: HtmlMod*): HtmlElement = simple.button(mods) + + override def options(mods: HtmlMod*): HtmlElement = simple.options(mods) + + override def option( + label: String, + isActive: Signal[Boolean], + isSelected: Signal[Boolean] + )(mods: HtmlMod*): HtmlElement = + simple.option(isActive, isSelected)( + simple.optionValue(label), + isSelected.childWhenTrue(simple.checkmark(isActive)) + ) + + override def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement = + val selectedFile: Var[Option[String]] = Var(None) + div( + cls := "mt-4 sm:mt-0 sm:flex-none", + L.label( + cls("block w-full"), + div( + buttonMods, + cls("cursor-pointer"), + buttons.sharedButtonMod, + buttons.secondaryButtonMod, + child <-- selectedFile.signal + .map(_.isDefined) + .switch( + icons.`paper-clip-solid`(svg.cls("w-6 h-6 mr-2")), + icons.upload(svg.cls("w-6 h-6 mr-2")) + ), + span(child.text <-- selectedFile.signal.map(_.getOrElse(title))) + ), + L.input( + cls("hidden"), + tpe("file"), + inputMods, + inContext(thisNode => + onInput + .mapTo( + thisNode.ref.files.headOption.map(_.name) + ) --> selectedFile.writer + ) + ) + ) + ) + +object TailwindUIFormUIFactory extends TailwindUIFormUIFactory diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/UserMenuComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/UserMenuComponentsModule.scala new file mode 100644 index 0000000..71d382b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/UserMenuComponentsModule.scala @@ -0,0 +1,55 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait UserMenuComponentsModule: + object userMenu: + def navBarItem(button: HtmlElement, popup: HtmlElement): HtmlElement = + div( + cls("relative ml-3"), + div(button), + popup + ) + + def userName(name: String): HtmlElement = + span(cls("text-white"), name) + + def avatar(href: String): HtmlElement = + img( + src(href), + cls("h-8 w-8 rounded-full") + ) + + def menuButton(userDetails: Node*): HtmlElement = + button( + tpe("button"), + cls( + "flex max-w-xs items-center rounded-full bg-indigo-600 text-sm focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-indigo-600" + ), + idAttr("user-menu-button"), + aria.hasPopup(true), + userDetails, + span(cls("sr-only"), "Open user menu") + ) + + def popup(menuItems: HtmlElement*): HtmlElement = + div( + cls( + "absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none" + ), + role("menu"), + aria.orientation("vertical"), + aria.labelledBy("user-menu-button"), + tabIndex(-1), + menuItems + ) + + def menuItem(id: String, label: Node): HtmlElement = + a( + cls("block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"), + href("#"), + role("menuitem"), + tabIndex(-1), + idAttr(s"user-menu-item-${id}"), + label + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ZeroComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ZeroComponentsModule.scala new file mode 100644 index 0000000..4278d55 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ZeroComponentsModule.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.LaminarExtensions.* +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage +import works.iterative.core.MessageId + +trait ZeroComponentsModule: + object zero: + def message(id: MessageId)(using ComponentContext[?]): HtmlElement = + message(UserMessage(id), UserMessage(s"${id}.description")) + + def message(headerMessage: UserMessage, descriptionElement: HtmlMod)(using + ComponentContext[?] + ): HtmlElement = + message(headerMessage.asElement, descriptionElement) + + def message(headerMessage: UserMessage, descriptionMessage: UserMessage)( + using ComponentContext[?] + ): HtmlElement = + message( + headerMessage.asElement, + p(cls("mt-1 text-sm text-gray-500"), descriptionMessage.asElement) + ) + + def message( + headerElement: HtmlMod, + descriptionElement: HtmlMod + ): HtmlElement = + div( + cls("text-center"), + h3( + cls("mt-2 text-sm font-semibold text-gray-900"), + headerElement + ), + descriptionElement + ) diff --git a/ui/scenarios/src/main/scala/works/iterative/ui/FormsScenarioModule.scala b/ui/scenarios/src/main/scala/works/iterative/ui/FormsScenarioModule.scala new file mode 100644 index 0000000..0c9900a --- /dev/null +++ b/ui/scenarios/src/main/scala/works/iterative/ui/FormsScenarioModule.scala @@ -0,0 +1,66 @@ +package works.iterative.ui + +import com.raquo.laminar.api.L.* +import works.iterative.core.* +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.forms.* +import works.iterative.ui.components.laminar.tailwind.ui.{TailwindUICatalogue, TailwindUIFormBuilderModule, TailwindUIFormUIFactory} +import works.iterative.ui.scenarios.Scenario +import works.iterative.ui.scenarios.Scenario.Id + +object FormsScenarioModule + extends Scenario + with TailwindUIFormBuilderModule + with TailwindUIFormUIFactory: + + override val id: Id = "registrace" + + override val label: String = "Žádost o registraci" + + override def element(using ComponentContext[?]): HtmlElement = + val schema = + import FormSchema.* + val kontaktniOsobaSchema: FormSchema[KontaktniOsoba] = + ( + Control[UserName]("jmeno") *: Control[Email]( + "email" + ) *: FormSchema.Unit + ).map( + KontaktniOsoba.apply + )(k => (k.jmeno, k.email)) + + val adresaSchema: FormSchema[Adresa] = + ( + Control[PlainOneLine]("ulice") *: Control[PlainOneLine]( + "mesto" + ) *: Control[PSC]("psc") *: Control[Country]( + "country" + ) *: FormSchema.Unit + ).map( + Adresa.apply + )(a => (a.ulice, a.mesto, a.psc, a.country)) + + val zadatelSchema: FormSchema[Zadatel] = + ( + Control[PlainOneLine]("nazev") *: Control[IC]( + "ic" + ) *: adresaSchema *: FormSchema.Unit + ).map( + Zadatel.apply + )(z => (z.nazev, z.ic, z.adresa)) + + Section( + "zadost-o-registraci", + Section("zadatel", zadatelSchema) *: + Section("administrator", kontaktniOsobaSchema) *: + Section("pccr", kontaktniOsobaSchema) *: FormSchema.Unit + ) + .map( + ZadostORegistraci.apply + )(z => (z._1, z._2, z._3)) + + import TailwindUIFormBuilderModule.given + // Form schema + TailwindUICatalogue.layout.card( + buildForm[ZadostORegistraci](schema, Observer.empty).build(None).elements* + ) diff --git a/ui/scenarios/src/main/scala/works/iterative/ui/Main.scala b/ui/scenarios/src/main/scala/works/iterative/ui/Main.scala index e067574..19d22b4 100644 --- a/ui/scenarios/src/main/scala/works/iterative/ui/Main.scala +++ b/ui/scenarios/src/main/scala/works/iterative/ui/Main.scala @@ -17,7 +17,7 @@ object Main extends ScenarioMain( "ui", - List(ComboBoxScenarioModule), + List(FormsScenarioModule), Messages, Css ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala new file mode 100644 index 0000000..d2bd069 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AlertComponentsModule.scala @@ -0,0 +1,160 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind +import works.iterative.ui.components.laminar.tailwind.color.ColorWeight + +trait AlertComponentsModule: + self: IconsModule => + object alerts: + // TODO: use context functions and builder pattern to build the alerts + def alert( + message: HtmlMod, + icon: SvgElement, + color: ColorKind, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + div( + cls := "rounded-md p-4", + color(50).bg, + div( + cls := "flex", + icon, + div( + cls := "ml-3", + title.map(t => + h3( + cls := "text-sm font-medium", + color(800).text, + t + ) + ), + div( + cls := "text-sm", + title.map(_ => cls("mt-2")), + color(700).text, + message + ), + actions.map { act => + div( + cls := "mt-4", + act(color) + ) + } + ), + mods(color) + ) + ) + + def warning( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-warning`(), + ColorKind.yellow, + title = title, + actions = actions, + mods = mods + ) + + def error( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-error`(), + ColorKind.red, + title = title, + actions = actions, + mods = mods + ) + + def success( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-success`(), + ColorKind.green, + title = title, + actions = actions, + mods = mods + ) + + def info( + message: HtmlMod, + title: Option[String] = None, + actions: Option[ColorKind => HtmlMod] = None, + mods: ColorKind => HtmlMod = _ => emptyMod + ): HtmlElement = + alert( + message, + icons.`alert-info`(), + ColorKind.blue, + title = title, + actions = actions, + mods = mods + ) + + def buttons(buttons: (ColorKind => HtmlMod)*): ColorKind => HtmlMod = + color => + div( + cls("-mx-2 -my-1.5 flex"), + buttons.map(_(color)) + ) + + def button(title: String)(mods: HtmlMod*): ColorKind => HtmlMod = color => + L.button( + tpe("button"), + cls( + "first:ml-0 ml-3 rounded-md px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(800).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + title, + mods + ) + + def closeMod(mods: HtmlMod): ColorKind => HtmlMod = color => + val closeIcon: SvgElement = + import svg.* + svg( + cls := "h-5 w-5", + viewBox := "0 0 20 20", + fill := "currentColor", + path( + d := "M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" + ) + ) + div( + cls("ml-auto pl-3"), + L.button( + cls( + "-mx-1.5 -my-1.5 inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2" + ), + color(50).bg, + color(500).text, + cls(s"hover:${color(100).bg.toCSS}"), + cls(s"focus:${color(600).ring.toCSS}"), + cls(s"focus:${color(50).ringOffset.toCSS}"), + mods, + aria.hidden := true, + closeIcon + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala new file mode 100644 index 0000000..fa374fe --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/AppShellComponentsModule.scala @@ -0,0 +1,52 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait AppShellComponentsModule: + object shell: + object stackedLight extends StackedAppShell: + override def navCls = cls("border-b border-gray-200 bg-white") + override def headerCls = emptyMod + override def pageWrapper(content: HtmlMod*): HtmlMod = + div(cls("py-10"), content) + + object stackedBranded extends StackedAppShell: + override def navCls = cls("bg-indigo-600") + override def headerCls = cls("bg-white shadown-sm") + override def pageWrapper(content: HtmlMod*): HtmlMod = content + +trait StackedAppShell: + protected def navCls: HtmlMod + protected def headerCls: HtmlMod + protected def pageWrapper(content: HtmlMod*): HtmlMod + + def apply(pageTitle: HtmlMod)(navbarItems: HtmlMod*)( + content: HtmlMod* + ): HtmlElement = + div( + cls("min-h-full"), + navTag( + navCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + div(cls("flex h-16 items-center justify-between"), navbarItems) + ) + ), + pageWrapper( + headerTag( + headerCls, + div( + cls("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"), + h1( + cls( + "text-3xl font-bold leading-tight tracking-tight text-gray-900" + ), + pageTitle + ) + ) + ), + mainTag( + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala new file mode 100644 index 0000000..f6d7d7f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BadgeComponentsModule.scala @@ -0,0 +1,29 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait BadgeComponentsModule: + + object badges: + def pill(name: String, color: ColorKind): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + color(800).text, + color(100).bg, + name + ) + + def pill(name: String, color: Signal[ColorKind]): HtmlElement = + p( + cls( + "px-2 inline-flex text-xs leading-5 font-semibold rounded-full" + ), + cls <-- color.map(_.apply(800).text.toCSS), + cls <-- color.map(_.apply(100).bg.toCSS), + name + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala new file mode 100644 index 0000000..03a2c9c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/BreadcrumbsModule.scala @@ -0,0 +1,42 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait BreadcrumbsModule: + self: IconsModule => + + object breadcrumbs: + def container(mods: HtmlMod*): HtmlElement = + navTag(cls("flex"), aria.label("Breadcrumb"), mods) + + def list(mods: HtmlMod*): HtmlElement = + ol(cls("flex items-center space-x-4"), role("list"), mods) + + def homeItem(mods: HtmlMod*): HtmlElement = + li( + div( + a( + href("#"), + cls("text-gray-400 hover:text-gray-500"), + icons.home(svg.cls("h-5 w-5 flex-shrink-0")), + span(cls("sr-only"), "Home"), + mods + ) + ) + ) + + def item(mods: HtmlMod*): HtmlElement = + li( + div( + cls("flex items-center"), + icons.`chevron-right-solid`( + svg.cls("h-5 w-5 flex-shrink-0 text-gray-400") + ), + a( + href("#"), + cls("ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"), + mods + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala new file mode 100644 index 0000000..27f3cf2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ButtonComponentsModule.scala @@ -0,0 +1,102 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +trait ButtonComponentsModule: + + object buttons: + + private inline def srHelp(text: String): Modifier[HtmlElement] = + span(cls := "sr-only", text) + + val sharedButtonClasses = + "inline-flex justify-center py-2 px-4 border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + + val sharedButtonMod: HtmlMod = cls(sharedButtonClasses) + + val primaryButtonClasses = + "border-transparent text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300" + val primaryButtonMod: HtmlMod = cls(primaryButtonClasses) + + val secondaryButtonClasses = + "border-gray-300 text-gray-700 hover:bg-gray-50" + val secondaryButtonMod: HtmlMod = cls(secondaryButtonClasses) + + val neutralButtonClasses = + "border-gray-300 text-gray-700 bg-white hover:bg-gray-50" + val neutralButtonMod: HtmlMod = cls(neutralButtonClasses) + + val positiveButtonClasses = + "border-transparent text-white bg-green-600 hover:bg-green-700" + val positiveButtonMod: HtmlMod = cls(positiveButtonClasses) + + val negativeButtonClasses = + "border-transparent text-white bg-red-600 hover:bg-red-700" + val negativeButtonMod: HtmlMod = cls(negativeButtonClasses) + + def button( + text: Modifier[HtmlElement], + id: Option[String], + icon: Option[SvgElement] = None, + buttonType: String = "submit", + primary: Boolean = false + )(mods: HtmlMod*): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe(buttonType), + sharedButtonMod, + if primary then primaryButtonMod else secondaryButtonMod, + icon, + text, + mods + ) + + def primaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "submit" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, true)(mods*) + + def secondaryButton( + text: Modifier[HtmlElement], + id: Option[String] = None, + icon: Option[SvgElement] = None, + buttonType: String = "button" + )(mods: Modifier[HtmlElement]*): HtmlElement = + button(text, id, icon, buttonType, false)(mods*) + + def inlineButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "ml-1 inline-flex h-4 w-4 flex-shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-500", + srText.map(srHelp(_)), + icon + ) + + def iconButton( + icon: SvgElement, + id: Option[String], + srText: Option[String] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.button( + id.map(idAttr(_)), + tpe := "button", + cls := "inline-flex justify-center px-3.5 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500", + icon, + srText.map(srHelp(_)), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala new file mode 100644 index 0000000..ae4ab86 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ComboboxModule.scala @@ -0,0 +1,56 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* + +import io.laminext.syntax.core.* + +trait ComboboxModule: + self: IconsModule => + + object combobox: + object simple: + def container(mods: HtmlMod*): Div = div(cls("relative mt-2"), mods) + + def button(mods: HtmlMod*): Button = + L.button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + mods + ) + + def options(mods: HtmlMod*) = + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + role := "listbox", + mods + ) + + def option(isActive: Signal[Boolean], isSelected: Signal[Boolean])( + mods: HtmlMod* + ) = + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- isActive, + cls.toggle("text-gray-900") <-- isActive.not, + cls.toggle("font-semibold") <-- isSelected, + mods + ) + + def optionValue(l: String): HtmlElement = + span(cls("block truncate"), l) + + def checkmark(isActive: Signal[Boolean]): HtmlElement = + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- isActive.not, + cls.toggle("text-white") <-- isActive, + icons.check(svg.cls("h-5 w-5")) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala new file mode 100644 index 0000000..f5a8873 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ContainerComponentsModule.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ContainerComponentsModule: + object container: + /** Full-width on mobile, constrained with padded content above */ + def default(content: HtmlMod*): HtmlElement = + div(cls("mx-auto max-w-7xl sm:px-6 lg:px-8"), content) + + def padded(content: HtmlMod*): HtmlElement = + default(cls("px-4"), content) + + def narrow(content: HtmlMod*): HtmlElement = + padded( + div(cls("mx-auto max-w-3xl"), content) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala new file mode 100644 index 0000000..d601ab6 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/DetailComponentsModule.scala @@ -0,0 +1,117 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import works.iterative.core.FileRef +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.laminar.* +import works.iterative.core.UserMessage + +trait DetailComponentsModule: + self: IconsModule => + object details: + def sectionHeader( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls( + "flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls := "px-4 sm:px-0", + h3( + cls := "text-base font-semibold leading-7 text-gray-900", + title + ), + subtitle.map(st => + p( + cls := "mt-1 max-w-2xl text-sm leading-6 text-gray-500", + st + ) + ) + ), + div(cls("flex-shrink-0"), actions) + ) + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]], + actions: Modifier[HtmlElement]* + )(content: HtmlMod): HtmlElement = + div(sectionHeader(title, subtitle, actions), content) + + def fields(items: Node*): HtmlElement = + div( + dl( + cls := "divide-y divide-gray-100", + items + ) + ) + + def field( + title: Node, + content: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls := "px-2 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0", + dt( + cls := "text-sm font-medium leading-6 text-gray-900", + title + ), + dd( + cls := "mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0", + content + ) + ) + + def files( + fs: Seq[FileRef], + fileMods: Option[(FileRef, Int) => HtmlMod] = None + )( + mods: HtmlMod* + )(using ComponentContext[?]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-100 rounded-md border border-gray-200", + fs.zipWithIndex + .map((f, i) => + fileMods match + case Some(fm) => file(f)(fm(f, i)) + case _ => file(f)() + ), + mods + ) + + def file(f: FileRef)(mods: HtmlMod*)(using + ComponentContext[?] + ): HtmlElement = + li( + cls := "flex items-center justify-between py-4 pl-4 pr-5 text-sm leading-6", + div( + cls := "flex w-0 flex-1 items-center", + icons.`paper-clip-solid`(), + div( + cls := "ml-4 flex min-w-0 flex-1 gap-2", + span( + cls := "truncate font-medium", + f.name + ), + f.sizeString.map(size => + span(cls := "flex-shrink-0 text-gray-400", size) + ) + ) + ), + div( + cls := "ml-4 flex-shrink-0", + a( + href := f.url, + target := "_blank", + cls := "font-medium text-indigo-600 hover:text-indigo-500", + UserMessage("file.download").asString + ) + ), + mods + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala new file mode 100644 index 0000000..e9d7f1b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/FormComponentsModule.scala @@ -0,0 +1,202 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html +import io.laminext.syntax.core.* + +trait FormComponentsModule: + self: IconsModule => + + object forms: + + val inputClasses = + "shadow-sm block focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md" + + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls("space-y-6 sm:space-y-5"), + div( + h3(cls("text-lg leading-6 font-medium text-gray-900"), title), + subtitle.map(st => p(cls("mt-1 max-w-2xl text-sm text-gray-500"), st)) + ), + div(cls("mt-6 sm:mt-5 space-y-6 sm:space-y-5"), content) + ) + + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: Modifier[HtmlElement]* + ): ReactiveHtmlElement[html.Label] = + L.label( + cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), + forId.map(id => L.forId(id)), + labelText, + if required then sup(cls("text-gray-400"), "* povinné pole") + else emptyMod, + mods + ) + + def field( + label: Modifier[HtmlElement] + )(content: Modifier[HtmlElement]*): HtmlElement = + div( + cls( + "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5" + ), + label, + div(cls("mt-1 sm:mt-0 sm:col-span-2"), content) + ) + + def field( + id: String, + labelText: String, + input: HtmlElement, + help: Option[String] + ): HtmlElement = + field( + label(labelText, Some(id))() + )( + input.amend(idAttr(id)), + help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) + ) + + def form(mods: Modifier[HtmlElement]*)( + sections: Modifier[HtmlElement]* + )(actions: Modifier[HtmlElement]*): ReactiveHtmlElement[html.Form] = + L.form( + cls("space-y-8 divide-y divide-gray-200"), + mods, + sections, + div( + cls("pt-5"), + div(cls("flex justify-end"), actions) + ) + ) + + def inlineForm( + mods: Modifier[HtmlElement]* + ): HtmlElement = + L.form(cls("flex space-x-4"), mods) + + def errorTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-red-600") + + def validationError(text: Modifier[HtmlElement]): HtmlElement = + p(errorTextMods, text) + + def helpTextMods: Modifier[HtmlElement] = + cls("mt-2 text-sm text-gray-500") + + def fieldHelp(text: Modifier[HtmlElement]): HtmlElement = + p(helpTextMods, text) + + def inputField( + id: String, + labelText: String, + placeholderText: Option[String] = None, + inputType: String = "text", + helpText: Option[String] = None + ): HtmlElement = + field( + id, + labelText, + input(id, inputType, placeholderText)(), + helpText + ) + + def input( + id: String, + inputType: String = "text", + placeholderText: Option[String] = None + )(mods: HtmlMod*): HtmlElement = + L.input( + cls(inputClasses), + idAttr(id), + nameAttr(id), + placeholderText.map(placeholder(_)), + tpe(inputType), + mods + ) + + def comboBoxSimple( + options: List[(String, String)], + selectedInitially: Option[String] = None, + id: Option[String] = None, + name: Option[String] = None + ): HtmlElement = + val expanded = Var(false) + val selected = Var(selectedInitially) + div( + cls("relative mt-2"), + L.input( + cls( + "w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + ), + id.map(idAttr(_)), + name.map(nameAttr(_)), + tpe("text"), + role("combobox"), + aria.controls("options"), + aria.expanded <-- expanded + ), + button( + cls( + "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + ), + tpe("button"), + icons.chevronUpDown(svg.cls("h-5 w-5 text-gray-400")), + onClick.preventDefault --> (_ => expanded.toggle()) + ), + ul( + cls( + "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + ), + cls.toggle("hidden") <-- expanded.signal.not, + id.map(i => idAttr(s"${i}-options")), + role := "listbox", + // Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation. + // Active: "text-white bg-indigo-600", Not Active: "text-gray-900" + for (((v, l), i) <- options.zipWithIndex) + yield + val active = Var(false) + val isSelected = selected.signal.map(_.contains(v)) + li( + cls( + "relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900" + ), + cls.toggle("text-white bg-indigo-600") <-- active.signal, + cls.toggle("text-gray-900") <-- active.signal.not, + cls.toggle("font-semibold") <-- isSelected, + id.map(cid => idAttr(s"${cid}-option-${i}")), + role := "option", + tabIndex := -1, + // Selected: "font-semibold" + span(cls("block truncate"), l), + // Checkmark, only display for selected option. + // Active: "text-white", Not Active: "text-indigo-600" + isSelected.childWhenTrue( + span( + cls("absolute inset-y-0 right-0 flex items-center pr-4"), + cls.toggle("text-indigo-600") <-- active.signal.not, + cls.toggle("text-white") <-- active.signal, + icons.check(svg.cls("h-5 w-5")) + ) + ), + onClick.preventDefault.mapTo( + v + ) --> selected.writer.contramapSome, + onMouseEnter --> (_ => active.set(true)), + onMouseLeave --> (_ => active.set(false)) + ) + // More items... + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala new file mode 100644 index 0000000..6a44cce --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/HeadingsComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait HeadingsComponentsModule: + object headings: + def section(title: Node, actions: HtmlElement*): HtmlElement = + div( + cls("border-b border-gray-200 pb-5"), + div( + cls("sm:flex sm:items-center sm:justify-between"), + h3(cls("text-base font-semibold leading-6 text-gray-900"), title), + div( + cls("mt-3 flex sm:ml-4 sm:mt-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) + + def sectionWithSubtitle( + title: Node, + subtitle: Node, + actions: HtmlElement* + ): HtmlElement = + div( + cls("border-b border-gray-200 bg-white px-4 py-5 sm:px-6"), + div( + cls( + "-ml-4 -mt-4 flex flex-wrap items-center justify-between sm:flex-nowrap" + ), + div( + cls("ml-4 mt-4"), + h3( + cls("text-base font-semibold leading-6 text-gray-900"), + title + ), + p( + cls("mt-1 text-sm text-gray-500"), + subtitle + ) + ), + div( + cls("ml-4 mt-4 flex-shrink-0"), + if actions.isEmpty then emptyMod + else + nodeSeq( + actions.head, + actions.tail.map(_.amend(cls("ml-3"))) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala new file mode 100644 index 0000000..be16e48 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/IconsModule.scala @@ -0,0 +1,321 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.codecs +import works.iterative.ui.components.laminar.CustomAttrs + +trait IconsModule: + object icons: + import svg.* + + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + private def withDefault( + mods: Seq[Modifier[SvgElement]], + default: Modifier[SvgElement] + ): Modifier[SvgElement] = + if mods.isEmpty then default else mods + + def avatarPlaceholder(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-8 w-8"), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d( + "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + ) + + def close(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-2 w-2"), + stroke := "currentColor", + fill := "none", + viewBox := "0 0 8 8", + path( + strokeLineCap := "round", + strokeWidth := "1.5", + d := "M1 1l6 6m0-6L1 7" + ) + ) + + def upload(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 24 24"), + path(d := "M0 0h24v24H0z", fill("none")), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + + def home(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("currentColor"), + viewBox("0 0 20 20"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + clipRule := "evenodd", + fillRule := "evenodd", + d := "M9.293 2.293a1 1 0 011.414 0l7 7A1 1 0 0117 11h-1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-3a1 1 0 00-1-1H9a1 1 0 00-1 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-6H3a1 1 0 01-.707-1.707l7-7z" + ) + ) + + def `x-mark-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + def `arrow-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18") + ) + ) + + def `chevron-left-outline`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("w-6 h-6")), + fill("none"), + viewBox("0 0 24 24"), + strokeWidth("1.5"), + stroke("currentColor"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d("M15.75 19.5L8.25 12l7.5-7.5") + ) + ) + + def `chevron-right-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + clipRule("evenodd"), + fillRule("evenodd"), + d( + "M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" + ) + ) + ) + + def `search-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" + ), + clipRule("evenodd") + ) + ) + + def `filter-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-gray-400")), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" + ), + clipRule("evenodd") + ) + ) + + def `document-chart-bar-outline`( + mods: Modifier[SvgElement]* + ): SvgElement = + svg( + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + strokeWidth := "1.5", + stroke := "currentColor", + withDefault(mods, cls := "h-6 w-6"), + path( + strokeLineCap := "round", + strokeLineJoin := "round", + d := "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25M9 16.5v.75m3-3v3M15 12v5.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" + ) + ) + + def `paper-clip-solid`(mods: Modifier[SvgElement]*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + cls := "flex-shrink-0 text-gray-400", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M15.621 4.379a3 3 0 00-4.242 0l-7 7a3 3 0 004.241 4.243h.001l.497-.5a.75.75 0 011.064 1.057l-.498.501-.002.002a4.5 4.5 0 01-6.364-6.364l7-7a4.5 4.5 0 016.368 6.36l-3.455 3.553A2.625 2.625 0 119.52 9.52l3.45-3.451a.75.75 0 111.061 1.06l-3.45 3.451a1.125 1.125 0 001.587 1.595l3.454-3.553a3 3 0 000-4.242z", + clipRule := "evenodd" + ) + ) + + def `exclamation-circle-solid`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-warning`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5 text-yellow-400")), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d( + "M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" + ), + clipRule("evenodd") + ) + ) + + def `alert-error`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-red-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z", + clipRule := "evenodd" + ) + ) + + def `alert-success`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-green-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z", + clipRule := "evenodd" + ) + ) + + def `alert-info`(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls := "h-5 w-5 text-blue-400"), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z", + clipRule := "evenodd" + ) + ) + + def spinner(mods: SvgMod*): SvgElement = svg( + withDefault(mods, cls("h-4 w-4")), + svgAttr("role", codecs.StringAsIsCodec, None) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + def arrowPath(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + fill("none"), + stroke("currentColor"), + strokeWidth("1.5"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + d( + "M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" + ) + ) + ) + + def chevronUpDown(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z", + clipRule := "evenodd" + ) + ) + + def check(mods: SvgMod*): SvgElement = + svg( + withDefault(mods, cls("h-5 w-5")), + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z", + clipRule := "evenodd" + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala new file mode 100644 index 0000000..70c4a7c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/LayoutModule.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* + +trait LayoutModule: + object layout: + def cardMod: HtmlMod = cls("bg-white shadow px-4 py-5 sm:rounded-lg sm:p-6") + def card(content: Modifier[HtmlElement]*): HtmlElement = + div(cardMod, content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala new file mode 100644 index 0000000..5b64ad7 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListComponentsModule.scala @@ -0,0 +1,88 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.laminar.tailwind.color.ColorKind + +trait ListComponentsModule: + self: BadgeComponentsModule => + + object list: + def label( + text: String, + color: ColorKind + ): HtmlElement = badges.pill(text, color) + + def item( + title: String, + subtitle: Option[String], + right: Modifier[HtmlElement] = emptyMod, + avatar: Option[Modifier[HtmlElement]] = None, + contentMod: Modifier[HtmlElement] = emptyMod + ): LI = + li( + cls("group"), + div( + contentMod, + cls( + "relative px-6 py-5 flex items-center space-x-3 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-pink-500" + ), + avatar.map(a => + div( + cls("flex-shrink-0"), + div( + cls( + "rounded-full text-indigo-200 bg-indigo-600 flex items-center justify-center w-10 h-10" + ), + a + ) + ) + ), + div( + cls("flex-1 min-w-0"), + p( + cls("text-sm font-medium text-gray-900"), + title, + span(cls("float-right"), right) + ), + subtitle.map(st => + p( + cls("text-sm text-gray-500 truncate"), + st + ) + ) + ) + ) + ) + + def unordered( + children: Modifier[HtmlElement] + ): ReactiveHtmlElement[org.scalajs.dom.html.UList] = + ul( + cls("relative z-0 divide-y divide-gray-200"), + role("list"), + children + ) + + def listSection( + header: String, + list: HtmlElement + ): Div = + div( + cls("relative"), + div( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + list + ) + + def navigation(sections: Modifier[HtmlElement]): HtmlElement = + navTag( + cls("flex-1 min-h-0 overflow-y-auto"), + sections + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala new file mode 100644 index 0000000..d4a2c56 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ListContainerComponentsModule.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait ListContainerComponentsModule: + object listContainer: + def simpleWithDividers(items: HtmlMod*) = + ul( + role("list"), + cls("divide-y divide-gray-200"), + items.map(li(cls("py-4"), _)) + ) + + def cardWithDividers(items: HtmlMod*) = + div( + cls("overflow-hidden rounded-md bg-white shadow"), + ul( + role := "list", + cls("divide-y divide-gray-200"), + items.map( + li( + cls("px-6 py-4"), + _ + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala new file mode 100644 index 0000000..684f0fb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ModalComponentsModule.scala @@ -0,0 +1,58 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* +import io.laminext.syntax.core.* + +trait ModalComponentsModule: + object modal: + def modalDialog( + content: Signal[Option[HtmlElement]], + isOpen: Signal[Boolean], + close: Observer[Unit] + ): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + onClick.preventDefault.mapTo(()) --> close + ) + ) + + div( + cls.toggle("hidden") <-- isOpen.not.combineWithFn(content)( + _ || _.isEmpty + ), + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-visible rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + child.maybe <-- content + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala new file mode 100644 index 0000000..866b529 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PageComponentsModule.scala @@ -0,0 +1,48 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L.* + +trait PageComponentsModule: + + object page: + def container( + children: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("max-w-7xl mx-auto h-full px-4 sm:px-6 lg:px-8 overflow-y-auto"), + children + ) + + def singleColumn( + header: Modifier[HtmlElement] + )(children: Modifier[HtmlElement]*): HtmlElement = + div( + cls("p-8 bg-gray-100 h-full"), + header, + children + ) + + def pageHeader( + title: Modifier[HtmlElement], + right: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None + ): HtmlElement = + div( + cls("pb-5 border-b border-gray-200"), + div(cls("float-right"), right), + h1( + cls("text-2xl leading-6 font-medium text-gray-900"), + title + ), + subtitle.map( + p( + cls("text-sm font-medium text-gray-500"), + _ + ) + ) + ) + + def clickable: Modifier[HtmlElement] = + cls("text-sm font-medium text-indigo-600 hover:text-indigo-400") diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala new file mode 100644 index 0000000..3603d98 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/PanelComponentsModule.scala @@ -0,0 +1,33 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait PanelComponentsModule: + object panel: + def basicCard(content: HtmlMod*) = + div( + cls("bg-white overflow-hidden shadow rounded-lg"), + div(cls("px-4 py-5 sm:p-6"), content) + ) + + /** Card, edge-to-edge on mobile */ + def cardEdgeToEdgeOnMobile(content: HtmlMod*) = + div( + cls("bg-white overflow-hidden shadow sm:rounded-lg"), + div(cls("px-4 py-5 sm:p-6"), content) + ) + + def cardWithHeader(header: HtmlMod*)(content: HtmlMod*) = + div( + cls( + "divide-y divide-gray-200 overflow-hidden rounded-lg bg-white shadow" + ), + div( + cls("px-4 py-5 sm:px-6"), + header + ), + div( + cls("px-4 py-5 sm:p-6"), + content + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TableComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TableComponentsModule.scala new file mode 100644 index 0000000..8f57c10 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TableComponentsModule.scala @@ -0,0 +1,95 @@ +package works.iterative.ui.components.laminar +package tailwind +package ui + +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +trait TableComponentsModule: + + object tables: + + def tableSection( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] = None, + actions: Modifier[HtmlElement]* + )( + table: Modifier[HtmlElement]* + ): HtmlElement = + div( + cls("px-4 sm:px-6 lg:px-8"), + div( + cls("sm:flex sm:items-center"), + div( + cls("sm:flex-auto"), + h1(cls("text-base font-semibold leading-6 text-gray-900"), title), + subtitle.map(st => p(cls("mt-2 text-sm text-gray-700"), st)) + ), + div(cls("mt-4 sm:ml-16 sm:mt-0 sm:flex-none"), actions) + ), + table + ) + + def tableContainer(table: ReactiveHtmlElement[html.Table]): HtmlElement = + div( + cls("mt-8 flow-root"), + div( + cls("-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"), + div( + cls("inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"), + table + ) + ) + ) + + def simpleTable(header: ReactiveHtmlElement[html.TableRow]*)( + body: ReactiveHtmlElement[html.TableRow]* + ): ReactiveHtmlElement[html.Table] = + table( + cls("min-w-full divide-y divide-gray-300"), + thead(header), + tbody(cls("divide-y divide-gray-200"), body) + ) + + def headerRow( + mods: HtmlMod* + )( + cells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] = + tr( + cells.zipWithIndex.map((c, i) => + if i == 0 then c.amend(cls("py-3.5 pl-4 pr-3 sm:pl-0")) + else if i == cells.length - 1 then + c.amend(cls("py-3.5 pr-4 pl-3 sm:pr-0")) + else c.amend(cls("py-3.5 px-3")) + ) + ) + + def dataRow( + mods: HtmlMod* + )( + cells: ReactiveHtmlElement[html.TableCell]* + ): ReactiveHtmlElement[html.TableRow] = + tr( + mods, + cells.zipWithIndex.map((c, i) => + if i == 0 then c.amend(cls("py-4 pl-4 pr-3 sm:pl-0")) + else if i == cells.length - 1 then + c.amend(cls("py-4 pr-4 pl-3 sm:pr-0")) + else c.amend(cls("py-4 px-3")) + ) + ) + + def headerCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] = + th( + cls("text-left text-sm font-semibold text-gray-900"), + content + ) + + def dataCell(content: HtmlMod): ReactiveHtmlElement[html.TableCell] = + td( + cls("text-sm text-gray-500"), + content + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUICatalogue.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUICatalogue.scala new file mode 100644 index 0000000..57ad709 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUICatalogue.scala @@ -0,0 +1,25 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +trait TailwindUICatalogueModule + extends BadgeComponentsModule + with ButtonComponentsModule + with HeadingsComponentsModule + with IconsModule + with FormComponentsModule + with LayoutModule + with ListComponentsModule + with ModalComponentsModule + with PageComponentsModule + with TableComponentsModule + with DetailComponentsModule + with ContainerComponentsModule + with PanelComponentsModule + with ListContainerComponentsModule + with AppShellComponentsModule + with UserMenuComponentsModule + with ZeroComponentsModule + with AlertComponentsModule + with ComboboxModule + with BreadcrumbsModule + +object TailwindUICatalogue extends TailwindUICatalogueModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormBuilderModule.scala new file mode 100644 index 0000000..81eb351 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormBuilderModule.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormMessagesResolver +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.forms.FormUIFactory +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait TailwindUIFormBuilderModule extends FormBuilderModule + +object TailwindUIFormBuilderModule: + given (using ctx: ComponentContext[_]): FormBuilderContext with + override def formMessagesResolver: FormMessagesResolver = + summon[FormMessagesResolver] + override def formUIFactory: FormUIFactory = TailwindUIFormUIFactory diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormUIFactory.scala new file mode 100644 index 0000000..3eea891 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/TailwindUIFormUIFactory.scala @@ -0,0 +1,161 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import works.iterative.ui.components.laminar.forms.FormUIFactory +import com.raquo.laminar.nodes.ReactiveHtmlElement +import com.raquo.laminar.api.L +import com.raquo.laminar.api.L.* +import TailwindUICatalogue.{forms, buttons, icons} +import org.scalajs.dom.html +import io.laminext.syntax.core.* +import com.raquo.laminar.tags.HtmlTag + +trait TailwindUIFormUIFactory extends FormUIFactory: + override def form(mods: HtmlMod*)(sections: HtmlMod*)( + actions: HtmlMod* + ): ReactiveHtmlElement[html.Form] = + forms.form(mods*)(sections*)(actions*) + + override def section(title: HtmlMod, subtitle: Option[HtmlMod])( + content: HtmlMod* + ): L.HtmlElement = + forms.section(title, subtitle)(content*) + + override def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( + mods: HtmlMod* + ): ReactiveHtmlElement[html.Label] = + forms.label(labelText, forId, required)(mods*) + + override def field(label: HtmlMod)(content: HtmlMod*): HtmlElement = + forms.field(label)(content*) + + override def submit(text: HtmlMod)(mods: HtmlMod*): HtmlElement = + buttons.primaryButton(text, None, None, "submit")(mods) + + override def cancel(text: HtmlMod)(mods: HtmlMod*): HtmlElement = + buttons.secondaryButton(text, None, None, "button")(cls("mr-3"), mods) + + override def validationError(text: HtmlMod): HtmlElement = + forms.validationError(text) + + override def errorTextMods: HtmlMod = forms.errorTextMods + + override def fieldHelp(text: HtmlMod): HtmlElement = + forms.fieldHelp(text) + + override def helpTextMods: HtmlMod = forms.helpTextMods + + private def renderInput[Ref <: org.scalajs.dom.HTMLElement]( + as: HtmlTag[Ref], + inError: Signal[Boolean], + mods: HtmlMod, + amendInput: ReactiveHtmlElement[Ref] => ReactiveHtmlElement[Ref] + ): Div = + div( + cls("relative w-full sm:max-w-xs rounded-md shadow-sm"), + amendInput( + as( + cls.toggle( + "text-red-900 ring-red-300 placeholder:text-red-300 focus:ring-red-500" + ) <-- inError, + cls.toggle( + "text-gray-900 ring-gray-300 placeholder:text-gray-400 focus:ring-indigo-600" + ) <-- inError.not, + cls( + "block w-full rounded-md border-0 py-1.5 shadow-sm ring-1 ring-inset focus:ring-2 focus:ring-inset sm:max-w-xs sm:text-sm sm:leading-6" + ), + mods + ) + ), + inError.childWhenTrue( + div( + cls( + "pointer-events-none absolute inset-y-0 right-0 pr-3 flex items-center" + ), + icons.`exclamation-circle-solid`(svg.cls("h-5 w-5 text-red-500")) + ) + ) + ) + + override def textarea( + inError: Signal[Boolean], + amendInput: TextArea => TextArea = identity + )(mods: HtmlMod*): HtmlElement = + renderInput(L.textArea, inError, mods, amendInput) + + override def input( + inError: Signal[Boolean], + amendInput: Input => Input = identity + )( + mods: HtmlMod* + ): HtmlElement = + renderInput(L.input, inError, mods, amendInput) + + override def select( + inError: Signal[Boolean], + amendInput: Select => Select = identity + )(mods: HtmlMod*): HtmlElement = + renderInput(L.select, inError, mods, amendInput) + override object combobox extends FormUIFactory.ComboboxComponents: + import works.iterative.ui.components.laminar.tailwind.ui.TailwindUICatalogue.combobox.simple + override def container( + inError: Signal[Boolean], + amendInput: Input => Input = identity + )(mods: HtmlMod*): HtmlElement = + simple.container(cls("sm:max-w-xs"), input(inError, amendInput)(), mods) + + override def button(mods: HtmlMod*): HtmlElement = simple.button(mods) + + override def options(mods: HtmlMod*): HtmlElement = simple.options(mods) + + override def option( + label: String, + isActive: Signal[Boolean], + isSelected: Signal[Boolean] + )(mods: HtmlMod*): HtmlElement = + simple.option(isActive, isSelected)( + simple.optionValue(label), + isSelected.childWhenTrue(simple.checkmark(isActive)) + ) + + override def fileInput(title: String)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement = + val selectedFile: Var[Option[String]] = Var(None) + div( + cls := "mt-4 sm:mt-0 sm:flex-none", + L.label( + cls("block w-full"), + div( + buttonMods, + cls("cursor-pointer"), + buttons.sharedButtonMod, + buttons.secondaryButtonMod, + child <-- selectedFile.signal + .map(_.isDefined) + .switch( + icons.`paper-clip-solid`(svg.cls("w-6 h-6 mr-2")), + icons.upload(svg.cls("w-6 h-6 mr-2")) + ), + span(child.text <-- selectedFile.signal.map(_.getOrElse(title))) + ), + L.input( + cls("hidden"), + tpe("file"), + inputMods, + inContext(thisNode => + onInput + .mapTo( + thisNode.ref.files.headOption.map(_.name) + ) --> selectedFile.writer + ) + ) + ) + ) + +object TailwindUIFormUIFactory extends TailwindUIFormUIFactory diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/UserMenuComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/UserMenuComponentsModule.scala new file mode 100644 index 0000000..71d382b --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/UserMenuComponentsModule.scala @@ -0,0 +1,55 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* + +trait UserMenuComponentsModule: + object userMenu: + def navBarItem(button: HtmlElement, popup: HtmlElement): HtmlElement = + div( + cls("relative ml-3"), + div(button), + popup + ) + + def userName(name: String): HtmlElement = + span(cls("text-white"), name) + + def avatar(href: String): HtmlElement = + img( + src(href), + cls("h-8 w-8 rounded-full") + ) + + def menuButton(userDetails: Node*): HtmlElement = + button( + tpe("button"), + cls( + "flex max-w-xs items-center rounded-full bg-indigo-600 text-sm focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-indigo-600" + ), + idAttr("user-menu-button"), + aria.hasPopup(true), + userDetails, + span(cls("sr-only"), "Open user menu") + ) + + def popup(menuItems: HtmlElement*): HtmlElement = + div( + cls( + "absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none" + ), + role("menu"), + aria.orientation("vertical"), + aria.labelledBy("user-menu-button"), + tabIndex(-1), + menuItems + ) + + def menuItem(id: String, label: Node): HtmlElement = + a( + cls("block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"), + href("#"), + role("menuitem"), + tabIndex(-1), + idAttr(s"user-menu-item-${id}"), + label + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ZeroComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ZeroComponentsModule.scala new file mode 100644 index 0000000..4278d55 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/tailwind/ZeroComponentsModule.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.laminar.tailwind.ui + +import com.raquo.laminar.api.L.* +import works.iterative.ui.components.laminar.LaminarExtensions.* +import works.iterative.ui.components.ComponentContext +import works.iterative.core.UserMessage +import works.iterative.core.MessageId + +trait ZeroComponentsModule: + object zero: + def message(id: MessageId)(using ComponentContext[?]): HtmlElement = + message(UserMessage(id), UserMessage(s"${id}.description")) + + def message(headerMessage: UserMessage, descriptionElement: HtmlMod)(using + ComponentContext[?] + ): HtmlElement = + message(headerMessage.asElement, descriptionElement) + + def message(headerMessage: UserMessage, descriptionMessage: UserMessage)( + using ComponentContext[?] + ): HtmlElement = + message( + headerMessage.asElement, + p(cls("mt-1 text-sm text-gray-500"), descriptionMessage.asElement) + ) + + def message( + headerElement: HtmlMod, + descriptionElement: HtmlMod + ): HtmlElement = + div( + cls("text-center"), + h3( + cls("mt-2 text-sm font-semibold text-gray-900"), + headerElement + ), + descriptionElement + ) diff --git a/ui/scenarios/src/main/scala/works/iterative/ui/FormsScenarioModule.scala b/ui/scenarios/src/main/scala/works/iterative/ui/FormsScenarioModule.scala new file mode 100644 index 0000000..0c9900a --- /dev/null +++ b/ui/scenarios/src/main/scala/works/iterative/ui/FormsScenarioModule.scala @@ -0,0 +1,66 @@ +package works.iterative.ui + +import com.raquo.laminar.api.L.* +import works.iterative.core.* +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.forms.* +import works.iterative.ui.components.laminar.tailwind.ui.{TailwindUICatalogue, TailwindUIFormBuilderModule, TailwindUIFormUIFactory} +import works.iterative.ui.scenarios.Scenario +import works.iterative.ui.scenarios.Scenario.Id + +object FormsScenarioModule + extends Scenario + with TailwindUIFormBuilderModule + with TailwindUIFormUIFactory: + + override val id: Id = "registrace" + + override val label: String = "Žádost o registraci" + + override def element(using ComponentContext[?]): HtmlElement = + val schema = + import FormSchema.* + val kontaktniOsobaSchema: FormSchema[KontaktniOsoba] = + ( + Control[UserName]("jmeno") *: Control[Email]( + "email" + ) *: FormSchema.Unit + ).map( + KontaktniOsoba.apply + )(k => (k.jmeno, k.email)) + + val adresaSchema: FormSchema[Adresa] = + ( + Control[PlainOneLine]("ulice") *: Control[PlainOneLine]( + "mesto" + ) *: Control[PSC]("psc") *: Control[Country]( + "country" + ) *: FormSchema.Unit + ).map( + Adresa.apply + )(a => (a.ulice, a.mesto, a.psc, a.country)) + + val zadatelSchema: FormSchema[Zadatel] = + ( + Control[PlainOneLine]("nazev") *: Control[IC]( + "ic" + ) *: adresaSchema *: FormSchema.Unit + ).map( + Zadatel.apply + )(z => (z.nazev, z.ic, z.adresa)) + + Section( + "zadost-o-registraci", + Section("zadatel", zadatelSchema) *: + Section("administrator", kontaktniOsobaSchema) *: + Section("pccr", kontaktniOsobaSchema) *: FormSchema.Unit + ) + .map( + ZadostORegistraci.apply + )(z => (z._1, z._2, z._3)) + + import TailwindUIFormBuilderModule.given + // Form schema + TailwindUICatalogue.layout.card( + buildForm[ZadostORegistraci](schema, Observer.empty).build(None).elements* + ) diff --git a/ui/scenarios/src/main/scala/works/iterative/ui/Main.scala b/ui/scenarios/src/main/scala/works/iterative/ui/Main.scala index e067574..19d22b4 100644 --- a/ui/scenarios/src/main/scala/works/iterative/ui/Main.scala +++ b/ui/scenarios/src/main/scala/works/iterative/ui/Main.scala @@ -17,7 +17,7 @@ object Main extends ScenarioMain( "ui", - List(ComboBoxScenarioModule), + List(FormsScenarioModule), Messages, Css ) diff --git a/ui/scenarios/src/main/scala/works/iterative/ui/Medeca/Details.scala b/ui/scenarios/src/main/scala/works/iterative/ui/Medeca/Details.scala new file mode 100644 index 0000000..f5d3a50 --- /dev/null +++ b/ui/scenarios/src/main/scala/works/iterative/ui/Medeca/Details.scala @@ -0,0 +1,25 @@ +package works.iterative.ui + +import works.iterative.core.UserName +import works.iterative.core.Email +import works.iterative.core.PlainOneLine + +type PSC = String +type Country = String +type IC = String + +final case class KontaktniOsoba( + jmeno: UserName, + email: Email +) + +final case class Adresa( + ulice: PlainOneLine, + mesto: PlainOneLine, + psc: String, + country: String +) + +final case class Zadatel(nazev: PlainOneLine, ic: String, adresa: Adresa) + +final case class ZadostORegistraci(zadatel: Zadatel, administrator: KontaktniOsoba, pccr: KontaktniOsoba) \ No newline at end of file