diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala index a47f5ba..bf149dc 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala @@ -7,9 +7,26 @@ def buttons: ButtonComponents trait ButtonComponents: + def primaryButton( + id: String, + text: String, + icon: Option[SvgElement] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement + + def secondaryButton( + id: String, + text: String, + icon: Option[SvgElement] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement + def inlineButton(id: String, icon: SvgElement)( mods: Modifier[HtmlElement]* ): HtmlElement + def iconButton(id: String, icon: SvgElement)( mods: Modifier[HtmlElement]* ): HtmlElement @@ -24,6 +41,34 @@ .opt(s"form.button.${id}.screenReaderHelp") .map(sr => span(cls := "sr-only", sr)) + override def primaryButton( + id: String, + text: String, + icon: Option[SvgElement] = None + )(mods: Modifier[HtmlElement]*): HtmlElement = + button( + tpe := "submit", + cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + icon, + text, + mods + ) + + override def secondaryButton( + id: String, + text: String, + icon: Option[SvgElement] = None + )(mods: Modifier[HtmlElement]*): HtmlElement = + button( + tpe("button"), + cls( + "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + icon, + text, + mods + ) + override def inlineButton(id: String, icon: SvgElement)( mods: Modifier[HtmlElement]* ): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala index a47f5ba..bf149dc 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala @@ -7,9 +7,26 @@ def buttons: ButtonComponents trait ButtonComponents: + def primaryButton( + id: String, + text: String, + icon: Option[SvgElement] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement + + def secondaryButton( + id: String, + text: String, + icon: Option[SvgElement] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement + def inlineButton(id: String, icon: SvgElement)( mods: Modifier[HtmlElement]* ): HtmlElement + def iconButton(id: String, icon: SvgElement)( mods: Modifier[HtmlElement]* ): HtmlElement @@ -24,6 +41,34 @@ .opt(s"form.button.${id}.screenReaderHelp") .map(sr => span(cls := "sr-only", sr)) + override def primaryButton( + id: String, + text: String, + icon: Option[SvgElement] = None + )(mods: Modifier[HtmlElement]*): HtmlElement = + button( + tpe := "submit", + cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + icon, + text, + mods + ) + + override def secondaryButton( + id: String, + text: String, + icon: Option[SvgElement] = None + )(mods: Modifier[HtmlElement]*): HtmlElement = + button( + tpe("button"), + cls( + "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + icon, + text, + mods + ) + override def inlineButton(id: String, icon: SvgElement)( mods: Modifier[HtmlElement]* ): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala index df42132..66094fd 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala @@ -14,7 +14,29 @@ def forms: FormComponents trait FormComponents: - def form(mods: Modifier[HtmlElement]*): HtmlElement + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] + )( + content: Modifier[HtmlElement]* + ): HtmlElement + + def field(label: Modifier[HtmlElement])( + content: Modifier[HtmlElement]* + ): HtmlElement + + def field( + id: String, + label: String, + input: HtmlElement, + help: Option[String] + ): HtmlElement + + def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( + actions: Modifier[HtmlElement]* + ): HtmlElement + + def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement def searchField(id: String, placeholderText: Option[String] = None)( mods: Modifier[HtmlElement]* @@ -32,7 +54,61 @@ self: IconsModule => override val forms = new FormComponents: - override def form( + override 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) + ) + + override 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) + ) + + override def field( + id: String, + label: String, + input: HtmlElement, + help: Option[String] + ): HtmlElement = + field( + L.label( + cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), + forId(id), + label + ) + )( + input.amend(idAttr(id)), + help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) + ) + + override def form(mods: Modifier[HtmlElement]*)( + sections: Modifier[HtmlElement]* + )(actions: Modifier[HtmlElement]*): HtmlElement = + L.form( + cls("space-y-8 divide-y divide-gray-200"), + mods, + sections, + div( + cls("pt-5"), + div(cls("flex justify-end"), actions) + ) + ) + + override def inlineForm( mods: Modifier[HtmlElement]* ): HtmlElement = L.form(cls("flex space-x-4"), mods) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala index a47f5ba..bf149dc 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala @@ -7,9 +7,26 @@ def buttons: ButtonComponents trait ButtonComponents: + def primaryButton( + id: String, + text: String, + icon: Option[SvgElement] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement + + def secondaryButton( + id: String, + text: String, + icon: Option[SvgElement] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement + def inlineButton(id: String, icon: SvgElement)( mods: Modifier[HtmlElement]* ): HtmlElement + def iconButton(id: String, icon: SvgElement)( mods: Modifier[HtmlElement]* ): HtmlElement @@ -24,6 +41,34 @@ .opt(s"form.button.${id}.screenReaderHelp") .map(sr => span(cls := "sr-only", sr)) + override def primaryButton( + id: String, + text: String, + icon: Option[SvgElement] = None + )(mods: Modifier[HtmlElement]*): HtmlElement = + button( + tpe := "submit", + cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + icon, + text, + mods + ) + + override def secondaryButton( + id: String, + text: String, + icon: Option[SvgElement] = None + )(mods: Modifier[HtmlElement]*): HtmlElement = + button( + tpe("button"), + cls( + "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + icon, + text, + mods + ) + override def inlineButton(id: String, icon: SvgElement)( mods: Modifier[HtmlElement]* ): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala index df42132..66094fd 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala @@ -14,7 +14,29 @@ def forms: FormComponents trait FormComponents: - def form(mods: Modifier[HtmlElement]*): HtmlElement + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] + )( + content: Modifier[HtmlElement]* + ): HtmlElement + + def field(label: Modifier[HtmlElement])( + content: Modifier[HtmlElement]* + ): HtmlElement + + def field( + id: String, + label: String, + input: HtmlElement, + help: Option[String] + ): HtmlElement + + def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( + actions: Modifier[HtmlElement]* + ): HtmlElement + + def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement def searchField(id: String, placeholderText: Option[String] = None)( mods: Modifier[HtmlElement]* @@ -32,7 +54,61 @@ self: IconsModule => override val forms = new FormComponents: - override def form( + override 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) + ) + + override 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) + ) + + override def field( + id: String, + label: String, + input: HtmlElement, + help: Option[String] + ): HtmlElement = + field( + L.label( + cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), + forId(id), + label + ) + )( + input.amend(idAttr(id)), + help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) + ) + + override def form(mods: Modifier[HtmlElement]*)( + sections: Modifier[HtmlElement]* + )(actions: Modifier[HtmlElement]*): HtmlElement = + L.form( + cls("space-y-8 divide-y divide-gray-200"), + mods, + sections, + div( + cls("pt-5"), + div(cls("flex justify-end"), actions) + ) + ) + + override def inlineForm( mods: Modifier[HtmlElement]* ): HtmlElement = L.form(cls("flex space-x-4"), mods) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LayoutModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LayoutModule.scala new file mode 100644 index 0000000..95ab654 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LayoutModule.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait LayoutModule: + def layout: LayoutComponents + + trait LayoutComponents: + def card(content: Modifier[HtmlElement]*): HtmlElement + +trait DefaultLayoutModule(using ctx: ComponentContext) extends LayoutModule: + override val layout: LayoutComponents = new LayoutComponents: + override def card(content: Modifier[HtmlElement]*): HtmlElement = + div(cls("bg-white shadow px-4 py-5 sm:rounded-lg sm:p-6"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala index a47f5ba..bf149dc 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala @@ -7,9 +7,26 @@ def buttons: ButtonComponents trait ButtonComponents: + def primaryButton( + id: String, + text: String, + icon: Option[SvgElement] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement + + def secondaryButton( + id: String, + text: String, + icon: Option[SvgElement] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement + def inlineButton(id: String, icon: SvgElement)( mods: Modifier[HtmlElement]* ): HtmlElement + def iconButton(id: String, icon: SvgElement)( mods: Modifier[HtmlElement]* ): HtmlElement @@ -24,6 +41,34 @@ .opt(s"form.button.${id}.screenReaderHelp") .map(sr => span(cls := "sr-only", sr)) + override def primaryButton( + id: String, + text: String, + icon: Option[SvgElement] = None + )(mods: Modifier[HtmlElement]*): HtmlElement = + button( + tpe := "submit", + cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + icon, + text, + mods + ) + + override def secondaryButton( + id: String, + text: String, + icon: Option[SvgElement] = None + )(mods: Modifier[HtmlElement]*): HtmlElement = + button( + tpe("button"), + cls( + "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + icon, + text, + mods + ) + override def inlineButton(id: String, icon: SvgElement)( mods: Modifier[HtmlElement]* ): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala index df42132..66094fd 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala @@ -14,7 +14,29 @@ def forms: FormComponents trait FormComponents: - def form(mods: Modifier[HtmlElement]*): HtmlElement + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] + )( + content: Modifier[HtmlElement]* + ): HtmlElement + + def field(label: Modifier[HtmlElement])( + content: Modifier[HtmlElement]* + ): HtmlElement + + def field( + id: String, + label: String, + input: HtmlElement, + help: Option[String] + ): HtmlElement + + def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( + actions: Modifier[HtmlElement]* + ): HtmlElement + + def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement def searchField(id: String, placeholderText: Option[String] = None)( mods: Modifier[HtmlElement]* @@ -32,7 +54,61 @@ self: IconsModule => override val forms = new FormComponents: - override def form( + override 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) + ) + + override 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) + ) + + override def field( + id: String, + label: String, + input: HtmlElement, + help: Option[String] + ): HtmlElement = + field( + L.label( + cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), + forId(id), + label + ) + )( + input.amend(idAttr(id)), + help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) + ) + + override def form(mods: Modifier[HtmlElement]*)( + sections: Modifier[HtmlElement]* + )(actions: Modifier[HtmlElement]*): HtmlElement = + L.form( + cls("space-y-8 divide-y divide-gray-200"), + mods, + sections, + div( + cls("pt-5"), + div(cls("flex justify-end"), actions) + ) + ) + + override def inlineForm( mods: Modifier[HtmlElement]* ): HtmlElement = L.form(cls("flex space-x-4"), mods) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LayoutModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LayoutModule.scala new file mode 100644 index 0000000..95ab654 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LayoutModule.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait LayoutModule: + def layout: LayoutComponents + + trait LayoutComponents: + def card(content: Modifier[HtmlElement]*): HtmlElement + +trait DefaultLayoutModule(using ctx: ComponentContext) extends LayoutModule: + override val layout: LayoutComponents = new LayoutComponents: + override def card(content: Modifier[HtmlElement]*): HtmlElement = + div(cls("bg-white shadow px-4 py-5 sm:rounded-lg sm:p-6"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ModalComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ModalComponentsModule.scala new file mode 100644 index 0000000..5b5490f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ModalComponentsModule.scala @@ -0,0 +1,65 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait ModalComponentsModule: + + def modal: ModalComponents + + trait ModalComponents: + def modalDialog( + content: HtmlElement, + onClose: Modifier[HtmlElement] + ): HtmlElement + +trait DefaultModalComponentsModule(using ctx: ComponentContext) + extends ModalComponentsModule: + + override val modal: ModalComponents = new ModalComponents: + + override def modalDialog( + content: HtmlElement, + closeMod: Modifier[HtmlElement] + ): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + closeMod + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + content + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala index a47f5ba..bf149dc 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala @@ -7,9 +7,26 @@ def buttons: ButtonComponents trait ButtonComponents: + def primaryButton( + id: String, + text: String, + icon: Option[SvgElement] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement + + def secondaryButton( + id: String, + text: String, + icon: Option[SvgElement] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement + def inlineButton(id: String, icon: SvgElement)( mods: Modifier[HtmlElement]* ): HtmlElement + def iconButton(id: String, icon: SvgElement)( mods: Modifier[HtmlElement]* ): HtmlElement @@ -24,6 +41,34 @@ .opt(s"form.button.${id}.screenReaderHelp") .map(sr => span(cls := "sr-only", sr)) + override def primaryButton( + id: String, + text: String, + icon: Option[SvgElement] = None + )(mods: Modifier[HtmlElement]*): HtmlElement = + button( + tpe := "submit", + cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + icon, + text, + mods + ) + + override def secondaryButton( + id: String, + text: String, + icon: Option[SvgElement] = None + )(mods: Modifier[HtmlElement]*): HtmlElement = + button( + tpe("button"), + cls( + "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + icon, + text, + mods + ) + override def inlineButton(id: String, icon: SvgElement)( mods: Modifier[HtmlElement]* ): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala index df42132..66094fd 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala @@ -14,7 +14,29 @@ def forms: FormComponents trait FormComponents: - def form(mods: Modifier[HtmlElement]*): HtmlElement + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] + )( + content: Modifier[HtmlElement]* + ): HtmlElement + + def field(label: Modifier[HtmlElement])( + content: Modifier[HtmlElement]* + ): HtmlElement + + def field( + id: String, + label: String, + input: HtmlElement, + help: Option[String] + ): HtmlElement + + def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( + actions: Modifier[HtmlElement]* + ): HtmlElement + + def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement def searchField(id: String, placeholderText: Option[String] = None)( mods: Modifier[HtmlElement]* @@ -32,7 +54,61 @@ self: IconsModule => override val forms = new FormComponents: - override def form( + override 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) + ) + + override 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) + ) + + override def field( + id: String, + label: String, + input: HtmlElement, + help: Option[String] + ): HtmlElement = + field( + L.label( + cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), + forId(id), + label + ) + )( + input.amend(idAttr(id)), + help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) + ) + + override def form(mods: Modifier[HtmlElement]*)( + sections: Modifier[HtmlElement]* + )(actions: Modifier[HtmlElement]*): HtmlElement = + L.form( + cls("space-y-8 divide-y divide-gray-200"), + mods, + sections, + div( + cls("pt-5"), + div(cls("flex justify-end"), actions) + ) + ) + + override def inlineForm( mods: Modifier[HtmlElement]* ): HtmlElement = L.form(cls("flex space-x-4"), mods) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LayoutModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LayoutModule.scala new file mode 100644 index 0000000..95ab654 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LayoutModule.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait LayoutModule: + def layout: LayoutComponents + + trait LayoutComponents: + def card(content: Modifier[HtmlElement]*): HtmlElement + +trait DefaultLayoutModule(using ctx: ComponentContext) extends LayoutModule: + override val layout: LayoutComponents = new LayoutComponents: + override def card(content: Modifier[HtmlElement]*): HtmlElement = + div(cls("bg-white shadow px-4 py-5 sm:rounded-lg sm:p-6"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ModalComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ModalComponentsModule.scala new file mode 100644 index 0000000..5b5490f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ModalComponentsModule.scala @@ -0,0 +1,65 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait ModalComponentsModule: + + def modal: ModalComponents + + trait ModalComponents: + def modalDialog( + content: HtmlElement, + onClose: Modifier[HtmlElement] + ): HtmlElement + +trait DefaultModalComponentsModule(using ctx: ComponentContext) + extends ModalComponentsModule: + + override val modal: ModalComponents = new ModalComponents: + + override def modalDialog( + content: HtmlElement, + closeMod: Modifier[HtmlElement] + ): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + closeMod + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + content + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index 266060a..bc25bf5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -2,6 +2,7 @@ import com.raquo.laminar.api.L.{*, given} +@deprecated("Use ModalComponentsModule", "2023-04-28") object Modal: def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = // This sequence tricks browser into displaying modal content centered diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala index a47f5ba..bf149dc 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ButtonComponentsModule.scala @@ -7,9 +7,26 @@ def buttons: ButtonComponents trait ButtonComponents: + def primaryButton( + id: String, + text: String, + icon: Option[SvgElement] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement + + def secondaryButton( + id: String, + text: String, + icon: Option[SvgElement] = None + )( + mods: Modifier[HtmlElement]* + ): HtmlElement + def inlineButton(id: String, icon: SvgElement)( mods: Modifier[HtmlElement]* ): HtmlElement + def iconButton(id: String, icon: SvgElement)( mods: Modifier[HtmlElement]* ): HtmlElement @@ -24,6 +41,34 @@ .opt(s"form.button.${id}.screenReaderHelp") .map(sr => span(cls := "sr-only", sr)) + override def primaryButton( + id: String, + text: String, + icon: Option[SvgElement] = None + )(mods: Modifier[HtmlElement]*): HtmlElement = + button( + tpe := "submit", + cls := "disabled:bg-indigo-300 ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + icon, + text, + mods + ) + + override def secondaryButton( + id: String, + text: String, + icon: Option[SvgElement] = None + )(mods: Modifier[HtmlElement]*): HtmlElement = + button( + tpe("button"), + cls( + "ml-2 bg-white inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + icon, + text, + mods + ) + override def inlineButton(id: String, icon: SvgElement)( mods: Modifier[HtmlElement]* ): HtmlElement = diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala index df42132..66094fd 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/FormComponentsModule.scala @@ -14,7 +14,29 @@ def forms: FormComponents trait FormComponents: - def form(mods: Modifier[HtmlElement]*): HtmlElement + def section( + title: Modifier[HtmlElement], + subtitle: Option[Modifier[HtmlElement]] + )( + content: Modifier[HtmlElement]* + ): HtmlElement + + def field(label: Modifier[HtmlElement])( + content: Modifier[HtmlElement]* + ): HtmlElement + + def field( + id: String, + label: String, + input: HtmlElement, + help: Option[String] + ): HtmlElement + + def form(mods: Modifier[HtmlElement]*)(sections: Modifier[HtmlElement]*)( + actions: Modifier[HtmlElement]* + ): HtmlElement + + def inlineForm(mods: Modifier[HtmlElement]*): HtmlElement def searchField(id: String, placeholderText: Option[String] = None)( mods: Modifier[HtmlElement]* @@ -32,7 +54,61 @@ self: IconsModule => override val forms = new FormComponents: - override def form( + override 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) + ) + + override 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) + ) + + override def field( + id: String, + label: String, + input: HtmlElement, + help: Option[String] + ): HtmlElement = + field( + L.label( + cls("block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2"), + forId(id), + label + ) + )( + input.amend(idAttr(id)), + help.map(h => p(cls("mt-2 text-sm text-gray-500"), h)) + ) + + override def form(mods: Modifier[HtmlElement]*)( + sections: Modifier[HtmlElement]* + )(actions: Modifier[HtmlElement]*): HtmlElement = + L.form( + cls("space-y-8 divide-y divide-gray-200"), + mods, + sections, + div( + cls("pt-5"), + div(cls("flex justify-end"), actions) + ) + ) + + override def inlineForm( mods: Modifier[HtmlElement]* ): HtmlElement = L.form(cls("flex space-x-4"), mods) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/LayoutModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LayoutModule.scala new file mode 100644 index 0000000..95ab654 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/LayoutModule.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait LayoutModule: + def layout: LayoutComponents + + trait LayoutComponents: + def card(content: Modifier[HtmlElement]*): HtmlElement + +trait DefaultLayoutModule(using ctx: ComponentContext) extends LayoutModule: + override val layout: LayoutComponents = new LayoutComponents: + override def card(content: Modifier[HtmlElement]*): HtmlElement = + div(cls("bg-white shadow px-4 py-5 sm:rounded-lg sm:p-6"), content) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/ModalComponentsModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ModalComponentsModule.scala new file mode 100644 index 0000000..5b5490f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/ModalComponentsModule.scala @@ -0,0 +1,65 @@ +package works.iterative.ui.components.laminar + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.ComponentContext + +trait ModalComponentsModule: + + def modal: ModalComponents + + trait ModalComponents: + def modalDialog( + content: HtmlElement, + onClose: Modifier[HtmlElement] + ): HtmlElement + +trait DefaultModalComponentsModule(using ctx: ComponentContext) + extends ModalComponentsModule: + + override val modal: ModalComponents = new ModalComponents: + + override def modalDialog( + content: HtmlElement, + closeMod: Modifier[HtmlElement] + ): HtmlElement = + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")), + closeMod + ) + ) + + div( + cls("fixed inset-0 z-20 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + content + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala index 266060a..bc25bf5 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/Modal.scala @@ -2,6 +2,7 @@ import com.raquo.laminar.api.L.{*, given} +@deprecated("Use ModalComponentsModule", "2023-04-28") object Modal: def render(elem: HtmlElement, close: Modifier[HtmlElement]): HtmlElement = // This sequence tricks browser into displaying modal content centered diff --git a/ui/shared/src/main/scala/works/iterative/ui/model/Capability.scala b/ui/shared/src/main/scala/works/iterative/ui/model/Capability.scala new file mode 100644 index 0000000..d90d121 --- /dev/null +++ b/ui/shared/src/main/scala/works/iterative/ui/model/Capability.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.model + +/** Marker trait for capabilities */ +trait Capability + +/** A trait for checking if a capability is supported. + * + * The idea is that the components will announce the possible capabilities used + * in the UI. The model will fill the capabilities with the actual capabilities + * of the user and the UI will check for a capability before rendering. + */ +trait CapabilityCheck: + def hasCapability[T <: Capability](c: T): Boolean + +/** A set of supported capabilities. + */ +case class Capabilities(capabilities: Set[Capability]) extends CapabilityCheck: + def hasCapability[T <: Capability](c: T): Boolean = capabilities.contains(c)