diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..cc44652 --- /dev/null +++ b/ui/src/services/files/components/tailwind/File.scala @@ -0,0 +1,23 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..cc44652 --- /dev/null +++ b/ui/src/services/files/components/tailwind/File.scala @@ -0,0 +1,23 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileList.scala b/ui/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..cc44652 --- /dev/null +++ b/ui/src/services/files/components/tailwind/File.scala @@ -0,0 +1,23 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileList.scala b/ui/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..cc44652 --- /dev/null +++ b/ui/src/services/files/components/tailwind/File.scala @@ -0,0 +1,23 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileList.scala b/ui/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileSelector.scala b/ui/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..cc44652 --- /dev/null +++ b/ui/src/services/files/components/tailwind/File.scala @@ -0,0 +1,23 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileList.scala b/ui/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileSelector.scala b/ui/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileTable.scala b/ui/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..86e6d85 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..cc44652 --- /dev/null +++ b/ui/src/services/files/components/tailwind/File.scala @@ -0,0 +1,23 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileList.scala b/ui/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileSelector.scala b/ui/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileTable.scala b/ui/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..86e6d85 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..12dd081 --- /dev/null +++ b/ui/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.Macros + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := Macros.size(size), + Icons.outline.user(Macros.size(size - 2)) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := Macros.size(size), + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..cc44652 --- /dev/null +++ b/ui/src/services/files/components/tailwind/File.scala @@ -0,0 +1,23 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileList.scala b/ui/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileSelector.scala b/ui/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileTable.scala b/ui/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..86e6d85 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..12dd081 --- /dev/null +++ b/ui/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.Macros + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := Macros.size(size), + Icons.outline.user(Macros.size(size - 2)) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := Macros.size(size), + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/ui/components/tailwind/Color.scala b/ui/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..cc44652 --- /dev/null +++ b/ui/src/services/files/components/tailwind/File.scala @@ -0,0 +1,23 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileList.scala b/ui/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileSelector.scala b/ui/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileTable.scala b/ui/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..86e6d85 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..12dd081 --- /dev/null +++ b/ui/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.Macros + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := Macros.size(size), + Icons.outline.user(Macros.size(size - 2)) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := Macros.size(size), + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/ui/components/tailwind/Color.scala b/ui/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..cc44652 --- /dev/null +++ b/ui/src/services/files/components/tailwind/File.scala @@ -0,0 +1,23 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileList.scala b/ui/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileSelector.scala b/ui/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileTable.scala b/ui/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..86e6d85 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..12dd081 --- /dev/null +++ b/ui/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.Macros + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := Macros.size(size), + Icons.outline.user(Macros.size(size - 2)) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := Macros.size(size), + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/ui/components/tailwind/Color.scala b/ui/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..cc44652 --- /dev/null +++ b/ui/src/services/files/components/tailwind/File.scala @@ -0,0 +1,23 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileList.scala b/ui/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileSelector.scala b/ui/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileTable.scala b/ui/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..86e6d85 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..12dd081 --- /dev/null +++ b/ui/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.Macros + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := Macros.size(size), + Icons.outline.user(Macros.size(size - 2)) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := Macros.size(size), + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/ui/components/tailwind/Color.scala b/ui/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..ddcfb25 --- /dev/null +++ b/ui/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,268 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9") + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z") + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14") + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414") + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z") + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + end outline + + object solid: + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z") + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z"), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z"), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z") + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z"), + clipRule("evenodd") + ) + ) + + end solid +end Icons diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..cc44652 --- /dev/null +++ b/ui/src/services/files/components/tailwind/File.scala @@ -0,0 +1,23 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileList.scala b/ui/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileSelector.scala b/ui/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileTable.scala b/ui/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..86e6d85 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..12dd081 --- /dev/null +++ b/ui/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.Macros + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := Macros.size(size), + Icons.outline.user(Macros.size(size - 2)) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := Macros.size(size), + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/ui/components/tailwind/Color.scala b/ui/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..ddcfb25 --- /dev/null +++ b/ui/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,268 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9") + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z") + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14") + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414") + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z") + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + end outline + + object solid: + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z") + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z"), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z"), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z") + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z"), + clipRule("evenodd") + ) + ) + + end solid +end Icons diff --git a/ui/src/ui/components/tailwind/LinkSupport.scala b/ui/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..cc44652 --- /dev/null +++ b/ui/src/services/files/components/tailwind/File.scala @@ -0,0 +1,23 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileList.scala b/ui/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileSelector.scala b/ui/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileTable.scala b/ui/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..86e6d85 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..12dd081 --- /dev/null +++ b/ui/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.Macros + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := Macros.size(size), + Icons.outline.user(Macros.size(size - 2)) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := Macros.size(size), + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/ui/components/tailwind/Color.scala b/ui/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..ddcfb25 --- /dev/null +++ b/ui/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,268 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9") + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z") + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14") + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414") + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z") + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + end outline + + object solid: + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z") + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z"), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z"), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z") + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z"), + clipRule("evenodd") + ) + ) + + end solid +end Icons diff --git a/ui/src/ui/components/tailwind/LinkSupport.scala b/ui/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..cc44652 --- /dev/null +++ b/ui/src/services/files/components/tailwind/File.scala @@ -0,0 +1,23 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileList.scala b/ui/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileSelector.scala b/ui/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileTable.scala b/ui/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..86e6d85 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..12dd081 --- /dev/null +++ b/ui/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.Macros + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := Macros.size(size), + Icons.outline.user(Macros.size(size - 2)) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := Macros.size(size), + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/ui/components/tailwind/Color.scala b/ui/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..ddcfb25 --- /dev/null +++ b/ui/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,268 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9") + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z") + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14") + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414") + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z") + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + end outline + + object solid: + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z") + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z"), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z"), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z") + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z"), + clipRule("evenodd") + ) + ) + + end solid +end Icons diff --git a/ui/src/ui/components/tailwind/LinkSupport.scala b/ui/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/src/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..cc44652 --- /dev/null +++ b/ui/src/services/files/components/tailwind/File.scala @@ -0,0 +1,23 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileList.scala b/ui/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileSelector.scala b/ui/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileTable.scala b/ui/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..86e6d85 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..12dd081 --- /dev/null +++ b/ui/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.Macros + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := Macros.size(size), + Icons.outline.user(Macros.size(size - 2)) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := Macros.size(size), + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/ui/components/tailwind/Color.scala b/ui/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..ddcfb25 --- /dev/null +++ b/ui/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,268 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9") + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z") + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14") + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414") + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z") + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + end outline + + object solid: + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z") + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z"), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z"), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z") + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z"), + clipRule("evenodd") + ) + ) + + end solid +end Icons diff --git a/ui/src/ui/components/tailwind/LinkSupport.scala b/ui/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/src/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/src/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..cc44652 --- /dev/null +++ b/ui/src/services/files/components/tailwind/File.scala @@ -0,0 +1,23 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileList.scala b/ui/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileSelector.scala b/ui/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileTable.scala b/ui/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..86e6d85 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..12dd081 --- /dev/null +++ b/ui/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.Macros + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := Macros.size(size), + Icons.outline.user(Macros.size(size - 2)) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := Macros.size(size), + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/ui/components/tailwind/Color.scala b/ui/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..ddcfb25 --- /dev/null +++ b/ui/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,268 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9") + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z") + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14") + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414") + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z") + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + end outline + + object solid: + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z") + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z"), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z"), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z") + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z"), + clipRule("evenodd") + ) + ) + + end solid +end Icons diff --git a/ui/src/ui/components/tailwind/LinkSupport.scala b/ui/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/src/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/src/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/ui/components/tailwind/form/ComboBox.scala b/ui/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..cc44652 --- /dev/null +++ b/ui/src/services/files/components/tailwind/File.scala @@ -0,0 +1,23 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileList.scala b/ui/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileSelector.scala b/ui/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileTable.scala b/ui/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..86e6d85 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..12dd081 --- /dev/null +++ b/ui/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.Macros + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := Macros.size(size), + Icons.outline.user(Macros.size(size - 2)) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := Macros.size(size), + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/ui/components/tailwind/Color.scala b/ui/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..ddcfb25 --- /dev/null +++ b/ui/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,268 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9") + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z") + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14") + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414") + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z") + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + end outline + + object solid: + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z") + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z"), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z"), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z") + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z"), + clipRule("evenodd") + ) + ) + + end solid +end Icons diff --git a/ui/src/ui/components/tailwind/LinkSupport.scala b/ui/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/src/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/src/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/ui/components/tailwind/form/ComboBox.scala b/ui/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/src/ui/components/tailwind/form/Form.scala b/ui/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/src/ui/components/tailwind/form/Form.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..cc44652 --- /dev/null +++ b/ui/src/services/files/components/tailwind/File.scala @@ -0,0 +1,23 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileList.scala b/ui/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileSelector.scala b/ui/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileTable.scala b/ui/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..86e6d85 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..12dd081 --- /dev/null +++ b/ui/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.Macros + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := Macros.size(size), + Icons.outline.user(Macros.size(size - 2)) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := Macros.size(size), + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/ui/components/tailwind/Color.scala b/ui/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..ddcfb25 --- /dev/null +++ b/ui/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,268 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9") + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z") + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14") + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414") + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z") + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + end outline + + object solid: + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z") + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z"), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z"), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z") + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z"), + clipRule("evenodd") + ) + ) + + end solid +end Icons diff --git a/ui/src/ui/components/tailwind/LinkSupport.scala b/ui/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/src/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/src/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/ui/components/tailwind/form/ComboBox.scala b/ui/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/src/ui/components/tailwind/form/Form.scala b/ui/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/src/ui/components/tailwind/form/Form.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/src/ui/components/tailwind/form/FormBody.scala b/ui/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,10 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..cc44652 --- /dev/null +++ b/ui/src/services/files/components/tailwind/File.scala @@ -0,0 +1,23 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileList.scala b/ui/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileSelector.scala b/ui/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileTable.scala b/ui/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..86e6d85 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..12dd081 --- /dev/null +++ b/ui/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.Macros + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := Macros.size(size), + Icons.outline.user(Macros.size(size - 2)) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := Macros.size(size), + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/ui/components/tailwind/Color.scala b/ui/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..ddcfb25 --- /dev/null +++ b/ui/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,268 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9") + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z") + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14") + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414") + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z") + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + end outline + + object solid: + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z") + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z"), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z"), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z") + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z"), + clipRule("evenodd") + ) + ) + + end solid +end Icons diff --git a/ui/src/ui/components/tailwind/LinkSupport.scala b/ui/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/src/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/src/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/ui/components/tailwind/form/ComboBox.scala b/ui/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/src/ui/components/tailwind/form/Form.scala b/ui/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/src/ui/components/tailwind/form/Form.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/src/ui/components/tailwind/form/FormBody.scala b/ui/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,10 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/src/ui/components/tailwind/form/FormFields.scala b/ui/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..cc44652 --- /dev/null +++ b/ui/src/services/files/components/tailwind/File.scala @@ -0,0 +1,23 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileList.scala b/ui/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileSelector.scala b/ui/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileTable.scala b/ui/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..86e6d85 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..12dd081 --- /dev/null +++ b/ui/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.Macros + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := Macros.size(size), + Icons.outline.user(Macros.size(size - 2)) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := Macros.size(size), + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/ui/components/tailwind/Color.scala b/ui/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..ddcfb25 --- /dev/null +++ b/ui/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,268 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9") + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z") + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14") + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414") + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z") + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + end outline + + object solid: + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z") + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z"), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z"), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z") + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z"), + clipRule("evenodd") + ) + ) + + end solid +end Icons diff --git a/ui/src/ui/components/tailwind/LinkSupport.scala b/ui/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/src/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/src/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/ui/components/tailwind/form/ComboBox.scala b/ui/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/src/ui/components/tailwind/form/Form.scala b/ui/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/src/ui/components/tailwind/form/Form.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/src/ui/components/tailwind/form/FormBody.scala b/ui/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,10 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/src/ui/components/tailwind/form/FormFields.scala b/ui/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/src/ui/components/tailwind/form/FormHeader.scala b/ui/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..cc44652 --- /dev/null +++ b/ui/src/services/files/components/tailwind/File.scala @@ -0,0 +1,23 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileList.scala b/ui/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileSelector.scala b/ui/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileTable.scala b/ui/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..86e6d85 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..12dd081 --- /dev/null +++ b/ui/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.Macros + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := Macros.size(size), + Icons.outline.user(Macros.size(size - 2)) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := Macros.size(size), + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/ui/components/tailwind/Color.scala b/ui/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..ddcfb25 --- /dev/null +++ b/ui/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,268 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9") + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z") + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14") + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414") + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z") + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + end outline + + object solid: + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z") + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z"), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z"), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z") + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z"), + clipRule("evenodd") + ) + ) + + end solid +end Icons diff --git a/ui/src/ui/components/tailwind/LinkSupport.scala b/ui/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/src/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/src/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/ui/components/tailwind/form/ComboBox.scala b/ui/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/src/ui/components/tailwind/form/Form.scala b/ui/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/src/ui/components/tailwind/form/Form.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/src/ui/components/tailwind/form/FormBody.scala b/ui/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,10 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/src/ui/components/tailwind/form/FormFields.scala b/ui/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/src/ui/components/tailwind/form/FormHeader.scala b/ui/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/src/ui/components/tailwind/form/FormRow.scala b/ui/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..cc44652 --- /dev/null +++ b/ui/src/services/files/components/tailwind/File.scala @@ -0,0 +1,23 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileList.scala b/ui/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileSelector.scala b/ui/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileTable.scala b/ui/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..86e6d85 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..12dd081 --- /dev/null +++ b/ui/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.Macros + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := Macros.size(size), + Icons.outline.user(Macros.size(size - 2)) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := Macros.size(size), + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/ui/components/tailwind/Color.scala b/ui/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..ddcfb25 --- /dev/null +++ b/ui/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,268 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9") + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z") + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14") + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414") + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z") + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + end outline + + object solid: + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z") + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z"), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z"), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z") + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z"), + clipRule("evenodd") + ) + ) + + end solid +end Icons diff --git a/ui/src/ui/components/tailwind/LinkSupport.scala b/ui/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/src/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/src/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/ui/components/tailwind/form/ComboBox.scala b/ui/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/src/ui/components/tailwind/form/Form.scala b/ui/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/src/ui/components/tailwind/form/Form.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/src/ui/components/tailwind/form/FormBody.scala b/ui/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,10 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/src/ui/components/tailwind/form/FormFields.scala b/ui/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/src/ui/components/tailwind/form/FormHeader.scala b/ui/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/src/ui/components/tailwind/form/FormRow.scala b/ui/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/src/ui/components/tailwind/form/FormSection.scala b/ui/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..cc44652 --- /dev/null +++ b/ui/src/services/files/components/tailwind/File.scala @@ -0,0 +1,23 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileList.scala b/ui/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileSelector.scala b/ui/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileTable.scala b/ui/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..86e6d85 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..12dd081 --- /dev/null +++ b/ui/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.Macros + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := Macros.size(size), + Icons.outline.user(Macros.size(size - 2)) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := Macros.size(size), + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/ui/components/tailwind/Color.scala b/ui/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..ddcfb25 --- /dev/null +++ b/ui/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,268 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9") + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z") + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14") + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414") + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z") + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + end outline + + object solid: + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z") + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z"), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z"), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z") + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z"), + clipRule("evenodd") + ) + ) + + end solid +end Icons diff --git a/ui/src/ui/components/tailwind/LinkSupport.scala b/ui/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/src/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/src/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/ui/components/tailwind/form/ComboBox.scala b/ui/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/src/ui/components/tailwind/form/Form.scala b/ui/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/src/ui/components/tailwind/form/Form.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/src/ui/components/tailwind/form/FormBody.scala b/ui/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,10 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/src/ui/components/tailwind/form/FormFields.scala b/ui/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/src/ui/components/tailwind/form/FormHeader.scala b/ui/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/src/ui/components/tailwind/form/FormRow.scala b/ui/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/src/ui/components/tailwind/form/FormSection.scala b/ui/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/src/ui/components/tailwind/list/BaseList.scala b/ui/src/ui/components/tailwind/list/BaseList.scala new file mode 100644 index 0000000..04972e8 --- /dev/null +++ b/ui/src/ui/components/tailwind/list/BaseList.scala @@ -0,0 +1,142 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom + +trait ListItem: + def key: String + def title: Modifier[HtmlElement] + def topRight: Modifier[HtmlElement] + def bottomLeft: Modifier[HtmlElement] + def bottomRight: Modifier[HtmlElement] + +trait ListRenderable[Item]: + extension (x: Item) def asItem: ListItem + +trait Navigable[Item]: + extension (x: Item) def navigate: Modifier[HtmlElement] + +object BaseList: + enum Color: + case Green, Yellow, Red + + case class IconText(text: HtmlElement, icon: SvgElement) + case class Tag(text: String, color: Color) + case class Row( + id: String, + title: String, + tag: Tag, + leftProps: List[IconText], + rightProp: IconText + ) + + trait AsRow[Data]: + extension (d: Data) def asRow: Row + + class RowListItem(d: Row) extends ListItem: + + def key: String = d.id + + def title: Modifier[HtmlElement] = d.title + + def topRight: Modifier[HtmlElement] = + inline def colorClass(color: Color): (String, Boolean) = + val c = color.toString.toLowerCase + s"bg-$c-100 text-$c-800" -> (d.tag.color == color) + + inline def colors = Map(Color.values.map(colorClass(_)): _*) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + cls := colors, + d.tag.text + ) + + def bottomLeft: Modifier[HtmlElement] = + div( + cls := "sm:flex", + d.leftProps.zipWithIndex.map { case (i, idx) => + p( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), + cls := "flex items-center text-sm text-gray-500", + i.icon, + i.text + ) + } + ) + + def bottomRight: Modifier[HtmlElement] = + div( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", + d.rightProp.icon, + d.rightProp.text + ) + + object Row: + given asRowRenderable[T: AsRow]: ListRenderable[T] with + extension (d: T) def asItem = new RowListItem(d.asRow) + + end Row + +class BaseList[RowData: ListRenderable]: + + protected def containerElement: HtmlTag[dom.html.Element] = div + protected def containerMods(rowData: RowData): Modifier[HtmlElement] = + emptyMod + protected def farRight: Modifier[HtmlElement] = emptyMod + + def render($data: Signal[List[RowData]]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-200", + children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) + ) + + private def row(d: RowData): HtmlElement = + val data = d.asItem + li( + containerElement( + containerMods(d), + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + data.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + data.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + data.bottomLeft, + data.bottomRight + ) + ), + farRight + ) + ) + ) + +trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) + extends BaseList[RowData]: + + override protected def containerElement: HtmlTag[dom.html.Element] = a + override protected def containerMods( + rowData: RowData + ): Modifier[HtmlElement] = + rowData.navigate + override protected def farRight: Modifier[HtmlElement] = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("w-6 h-6 text-gray-400") + ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..cc44652 --- /dev/null +++ b/ui/src/services/files/components/tailwind/File.scala @@ -0,0 +1,23 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileList.scala b/ui/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileSelector.scala b/ui/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileTable.scala b/ui/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..86e6d85 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..12dd081 --- /dev/null +++ b/ui/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.Macros + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := Macros.size(size), + Icons.outline.user(Macros.size(size - 2)) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := Macros.size(size), + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/ui/components/tailwind/Color.scala b/ui/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..ddcfb25 --- /dev/null +++ b/ui/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,268 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9") + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z") + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14") + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414") + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z") + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + end outline + + object solid: + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z") + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z"), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z"), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z") + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z"), + clipRule("evenodd") + ) + ) + + end solid +end Icons diff --git a/ui/src/ui/components/tailwind/LinkSupport.scala b/ui/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/src/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/src/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/ui/components/tailwind/form/ComboBox.scala b/ui/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/src/ui/components/tailwind/form/Form.scala b/ui/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/src/ui/components/tailwind/form/Form.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/src/ui/components/tailwind/form/FormBody.scala b/ui/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,10 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/src/ui/components/tailwind/form/FormFields.scala b/ui/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/src/ui/components/tailwind/form/FormHeader.scala b/ui/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/src/ui/components/tailwind/form/FormRow.scala b/ui/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/src/ui/components/tailwind/form/FormSection.scala b/ui/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/src/ui/components/tailwind/list/BaseList.scala b/ui/src/ui/components/tailwind/list/BaseList.scala new file mode 100644 index 0000000..04972e8 --- /dev/null +++ b/ui/src/ui/components/tailwind/list/BaseList.scala @@ -0,0 +1,142 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom + +trait ListItem: + def key: String + def title: Modifier[HtmlElement] + def topRight: Modifier[HtmlElement] + def bottomLeft: Modifier[HtmlElement] + def bottomRight: Modifier[HtmlElement] + +trait ListRenderable[Item]: + extension (x: Item) def asItem: ListItem + +trait Navigable[Item]: + extension (x: Item) def navigate: Modifier[HtmlElement] + +object BaseList: + enum Color: + case Green, Yellow, Red + + case class IconText(text: HtmlElement, icon: SvgElement) + case class Tag(text: String, color: Color) + case class Row( + id: String, + title: String, + tag: Tag, + leftProps: List[IconText], + rightProp: IconText + ) + + trait AsRow[Data]: + extension (d: Data) def asRow: Row + + class RowListItem(d: Row) extends ListItem: + + def key: String = d.id + + def title: Modifier[HtmlElement] = d.title + + def topRight: Modifier[HtmlElement] = + inline def colorClass(color: Color): (String, Boolean) = + val c = color.toString.toLowerCase + s"bg-$c-100 text-$c-800" -> (d.tag.color == color) + + inline def colors = Map(Color.values.map(colorClass(_)): _*) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + cls := colors, + d.tag.text + ) + + def bottomLeft: Modifier[HtmlElement] = + div( + cls := "sm:flex", + d.leftProps.zipWithIndex.map { case (i, idx) => + p( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), + cls := "flex items-center text-sm text-gray-500", + i.icon, + i.text + ) + } + ) + + def bottomRight: Modifier[HtmlElement] = + div( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", + d.rightProp.icon, + d.rightProp.text + ) + + object Row: + given asRowRenderable[T: AsRow]: ListRenderable[T] with + extension (d: T) def asItem = new RowListItem(d.asRow) + + end Row + +class BaseList[RowData: ListRenderable]: + + protected def containerElement: HtmlTag[dom.html.Element] = div + protected def containerMods(rowData: RowData): Modifier[HtmlElement] = + emptyMod + protected def farRight: Modifier[HtmlElement] = emptyMod + + def render($data: Signal[List[RowData]]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-200", + children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) + ) + + private def row(d: RowData): HtmlElement = + val data = d.asItem + li( + containerElement( + containerMods(d), + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + data.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + data.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + data.bottomLeft, + data.bottomRight + ) + ), + farRight + ) + ) + ) + +trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) + extends BaseList[RowData]: + + override protected def containerElement: HtmlTag[dom.html.Element] = a + override protected def containerMods( + rowData: RowData + ): Modifier[HtmlElement] = + rowData.navigate + override protected def farRight: Modifier[HtmlElement] = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("w-6 h-6 text-gray-400") + ) diff --git a/ui/src/ui/components/tailwind/list/IconText.scala b/ui/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/src/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..cc44652 --- /dev/null +++ b/ui/src/services/files/components/tailwind/File.scala @@ -0,0 +1,23 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileList.scala b/ui/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileSelector.scala b/ui/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileTable.scala b/ui/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..86e6d85 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..12dd081 --- /dev/null +++ b/ui/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.Macros + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := Macros.size(size), + Icons.outline.user(Macros.size(size - 2)) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := Macros.size(size), + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/ui/components/tailwind/Color.scala b/ui/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..ddcfb25 --- /dev/null +++ b/ui/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,268 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9") + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z") + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14") + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414") + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z") + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + end outline + + object solid: + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z") + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z"), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z"), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z") + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z"), + clipRule("evenodd") + ) + ) + + end solid +end Icons diff --git a/ui/src/ui/components/tailwind/LinkSupport.scala b/ui/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/src/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/src/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/ui/components/tailwind/form/ComboBox.scala b/ui/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/src/ui/components/tailwind/form/Form.scala b/ui/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/src/ui/components/tailwind/form/Form.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/src/ui/components/tailwind/form/FormBody.scala b/ui/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,10 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/src/ui/components/tailwind/form/FormFields.scala b/ui/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/src/ui/components/tailwind/form/FormHeader.scala b/ui/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/src/ui/components/tailwind/form/FormRow.scala b/ui/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/src/ui/components/tailwind/form/FormSection.scala b/ui/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/src/ui/components/tailwind/list/BaseList.scala b/ui/src/ui/components/tailwind/list/BaseList.scala new file mode 100644 index 0000000..04972e8 --- /dev/null +++ b/ui/src/ui/components/tailwind/list/BaseList.scala @@ -0,0 +1,142 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom + +trait ListItem: + def key: String + def title: Modifier[HtmlElement] + def topRight: Modifier[HtmlElement] + def bottomLeft: Modifier[HtmlElement] + def bottomRight: Modifier[HtmlElement] + +trait ListRenderable[Item]: + extension (x: Item) def asItem: ListItem + +trait Navigable[Item]: + extension (x: Item) def navigate: Modifier[HtmlElement] + +object BaseList: + enum Color: + case Green, Yellow, Red + + case class IconText(text: HtmlElement, icon: SvgElement) + case class Tag(text: String, color: Color) + case class Row( + id: String, + title: String, + tag: Tag, + leftProps: List[IconText], + rightProp: IconText + ) + + trait AsRow[Data]: + extension (d: Data) def asRow: Row + + class RowListItem(d: Row) extends ListItem: + + def key: String = d.id + + def title: Modifier[HtmlElement] = d.title + + def topRight: Modifier[HtmlElement] = + inline def colorClass(color: Color): (String, Boolean) = + val c = color.toString.toLowerCase + s"bg-$c-100 text-$c-800" -> (d.tag.color == color) + + inline def colors = Map(Color.values.map(colorClass(_)): _*) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + cls := colors, + d.tag.text + ) + + def bottomLeft: Modifier[HtmlElement] = + div( + cls := "sm:flex", + d.leftProps.zipWithIndex.map { case (i, idx) => + p( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), + cls := "flex items-center text-sm text-gray-500", + i.icon, + i.text + ) + } + ) + + def bottomRight: Modifier[HtmlElement] = + div( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", + d.rightProp.icon, + d.rightProp.text + ) + + object Row: + given asRowRenderable[T: AsRow]: ListRenderable[T] with + extension (d: T) def asItem = new RowListItem(d.asRow) + + end Row + +class BaseList[RowData: ListRenderable]: + + protected def containerElement: HtmlTag[dom.html.Element] = div + protected def containerMods(rowData: RowData): Modifier[HtmlElement] = + emptyMod + protected def farRight: Modifier[HtmlElement] = emptyMod + + def render($data: Signal[List[RowData]]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-200", + children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) + ) + + private def row(d: RowData): HtmlElement = + val data = d.asItem + li( + containerElement( + containerMods(d), + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + data.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + data.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + data.bottomLeft, + data.bottomRight + ) + ), + farRight + ) + ) + ) + +trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) + extends BaseList[RowData]: + + override protected def containerElement: HtmlTag[dom.html.Element] = a + override protected def containerMods( + rowData: RowData + ): Modifier[HtmlElement] = + rowData.navigate + override protected def farRight: Modifier[HtmlElement] = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("w-6 h-6 text-gray-400") + ) diff --git a/ui/src/ui/components/tailwind/list/IconText.scala b/ui/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/src/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/src/ui/components/tailwind/list/ListRow.scala b/ui/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..e5236a2 --- /dev/null +++ b/ui/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom + +object ListRow: + case class ViewModel( + title: String, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + containerElement: HtmlElement = div() + ) + + def apply($m: Signal[ViewModel]): HtmlElement = + li( + child <-- $m.map(m => + m.containerElement.amend( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + m.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + m.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + m.bottomLeft, + m.bottomRight + ) + ), + m.farRight + ) + ) + ) + ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..cc44652 --- /dev/null +++ b/ui/src/services/files/components/tailwind/File.scala @@ -0,0 +1,23 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileList.scala b/ui/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileSelector.scala b/ui/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileTable.scala b/ui/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..86e6d85 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..12dd081 --- /dev/null +++ b/ui/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.Macros + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := Macros.size(size), + Icons.outline.user(Macros.size(size - 2)) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := Macros.size(size), + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/ui/components/tailwind/Color.scala b/ui/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..ddcfb25 --- /dev/null +++ b/ui/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,268 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9") + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z") + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14") + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414") + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z") + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + end outline + + object solid: + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z") + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z"), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z"), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z") + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z"), + clipRule("evenodd") + ) + ) + + end solid +end Icons diff --git a/ui/src/ui/components/tailwind/LinkSupport.scala b/ui/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/src/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/src/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/ui/components/tailwind/form/ComboBox.scala b/ui/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/src/ui/components/tailwind/form/Form.scala b/ui/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/src/ui/components/tailwind/form/Form.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/src/ui/components/tailwind/form/FormBody.scala b/ui/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,10 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/src/ui/components/tailwind/form/FormFields.scala b/ui/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/src/ui/components/tailwind/form/FormHeader.scala b/ui/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/src/ui/components/tailwind/form/FormRow.scala b/ui/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/src/ui/components/tailwind/form/FormSection.scala b/ui/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/src/ui/components/tailwind/list/BaseList.scala b/ui/src/ui/components/tailwind/list/BaseList.scala new file mode 100644 index 0000000..04972e8 --- /dev/null +++ b/ui/src/ui/components/tailwind/list/BaseList.scala @@ -0,0 +1,142 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom + +trait ListItem: + def key: String + def title: Modifier[HtmlElement] + def topRight: Modifier[HtmlElement] + def bottomLeft: Modifier[HtmlElement] + def bottomRight: Modifier[HtmlElement] + +trait ListRenderable[Item]: + extension (x: Item) def asItem: ListItem + +trait Navigable[Item]: + extension (x: Item) def navigate: Modifier[HtmlElement] + +object BaseList: + enum Color: + case Green, Yellow, Red + + case class IconText(text: HtmlElement, icon: SvgElement) + case class Tag(text: String, color: Color) + case class Row( + id: String, + title: String, + tag: Tag, + leftProps: List[IconText], + rightProp: IconText + ) + + trait AsRow[Data]: + extension (d: Data) def asRow: Row + + class RowListItem(d: Row) extends ListItem: + + def key: String = d.id + + def title: Modifier[HtmlElement] = d.title + + def topRight: Modifier[HtmlElement] = + inline def colorClass(color: Color): (String, Boolean) = + val c = color.toString.toLowerCase + s"bg-$c-100 text-$c-800" -> (d.tag.color == color) + + inline def colors = Map(Color.values.map(colorClass(_)): _*) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + cls := colors, + d.tag.text + ) + + def bottomLeft: Modifier[HtmlElement] = + div( + cls := "sm:flex", + d.leftProps.zipWithIndex.map { case (i, idx) => + p( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), + cls := "flex items-center text-sm text-gray-500", + i.icon, + i.text + ) + } + ) + + def bottomRight: Modifier[HtmlElement] = + div( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", + d.rightProp.icon, + d.rightProp.text + ) + + object Row: + given asRowRenderable[T: AsRow]: ListRenderable[T] with + extension (d: T) def asItem = new RowListItem(d.asRow) + + end Row + +class BaseList[RowData: ListRenderable]: + + protected def containerElement: HtmlTag[dom.html.Element] = div + protected def containerMods(rowData: RowData): Modifier[HtmlElement] = + emptyMod + protected def farRight: Modifier[HtmlElement] = emptyMod + + def render($data: Signal[List[RowData]]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-200", + children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) + ) + + private def row(d: RowData): HtmlElement = + val data = d.asItem + li( + containerElement( + containerMods(d), + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + data.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + data.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + data.bottomLeft, + data.bottomRight + ) + ), + farRight + ) + ) + ) + +trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) + extends BaseList[RowData]: + + override protected def containerElement: HtmlTag[dom.html.Element] = a + override protected def containerMods( + rowData: RowData + ): Modifier[HtmlElement] = + rowData.navigate + override protected def farRight: Modifier[HtmlElement] = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("w-6 h-6 text-gray-400") + ) diff --git a/ui/src/ui/components/tailwind/list/IconText.scala b/ui/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/src/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/src/ui/components/tailwind/list/ListRow.scala b/ui/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..e5236a2 --- /dev/null +++ b/ui/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom + +object ListRow: + case class ViewModel( + title: String, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + containerElement: HtmlElement = div() + ) + + def apply($m: Signal[ViewModel]): HtmlElement = + li( + child <-- $m.map(m => + m.containerElement.amend( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + m.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + m.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + m.bottomLeft, + m.bottomRight + ) + ), + m.farRight + ) + ) + ) + ) diff --git a/ui/src/ui/components/tailwind/list/PropList.scala b/ui/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/src/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..cc44652 --- /dev/null +++ b/ui/src/services/files/components/tailwind/File.scala @@ -0,0 +1,23 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileList.scala b/ui/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileSelector.scala b/ui/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileTable.scala b/ui/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..86e6d85 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..12dd081 --- /dev/null +++ b/ui/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.Macros + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := Macros.size(size), + Icons.outline.user(Macros.size(size - 2)) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := Macros.size(size), + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/ui/components/tailwind/Color.scala b/ui/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..ddcfb25 --- /dev/null +++ b/ui/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,268 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9") + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z") + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14") + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414") + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z") + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + end outline + + object solid: + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z") + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z"), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z"), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z") + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z"), + clipRule("evenodd") + ) + ) + + end solid +end Icons diff --git a/ui/src/ui/components/tailwind/LinkSupport.scala b/ui/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/src/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/src/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/ui/components/tailwind/form/ComboBox.scala b/ui/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/src/ui/components/tailwind/form/Form.scala b/ui/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/src/ui/components/tailwind/form/Form.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/src/ui/components/tailwind/form/FormBody.scala b/ui/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,10 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/src/ui/components/tailwind/form/FormFields.scala b/ui/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/src/ui/components/tailwind/form/FormHeader.scala b/ui/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/src/ui/components/tailwind/form/FormRow.scala b/ui/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/src/ui/components/tailwind/form/FormSection.scala b/ui/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/src/ui/components/tailwind/list/BaseList.scala b/ui/src/ui/components/tailwind/list/BaseList.scala new file mode 100644 index 0000000..04972e8 --- /dev/null +++ b/ui/src/ui/components/tailwind/list/BaseList.scala @@ -0,0 +1,142 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom + +trait ListItem: + def key: String + def title: Modifier[HtmlElement] + def topRight: Modifier[HtmlElement] + def bottomLeft: Modifier[HtmlElement] + def bottomRight: Modifier[HtmlElement] + +trait ListRenderable[Item]: + extension (x: Item) def asItem: ListItem + +trait Navigable[Item]: + extension (x: Item) def navigate: Modifier[HtmlElement] + +object BaseList: + enum Color: + case Green, Yellow, Red + + case class IconText(text: HtmlElement, icon: SvgElement) + case class Tag(text: String, color: Color) + case class Row( + id: String, + title: String, + tag: Tag, + leftProps: List[IconText], + rightProp: IconText + ) + + trait AsRow[Data]: + extension (d: Data) def asRow: Row + + class RowListItem(d: Row) extends ListItem: + + def key: String = d.id + + def title: Modifier[HtmlElement] = d.title + + def topRight: Modifier[HtmlElement] = + inline def colorClass(color: Color): (String, Boolean) = + val c = color.toString.toLowerCase + s"bg-$c-100 text-$c-800" -> (d.tag.color == color) + + inline def colors = Map(Color.values.map(colorClass(_)): _*) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + cls := colors, + d.tag.text + ) + + def bottomLeft: Modifier[HtmlElement] = + div( + cls := "sm:flex", + d.leftProps.zipWithIndex.map { case (i, idx) => + p( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), + cls := "flex items-center text-sm text-gray-500", + i.icon, + i.text + ) + } + ) + + def bottomRight: Modifier[HtmlElement] = + div( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", + d.rightProp.icon, + d.rightProp.text + ) + + object Row: + given asRowRenderable[T: AsRow]: ListRenderable[T] with + extension (d: T) def asItem = new RowListItem(d.asRow) + + end Row + +class BaseList[RowData: ListRenderable]: + + protected def containerElement: HtmlTag[dom.html.Element] = div + protected def containerMods(rowData: RowData): Modifier[HtmlElement] = + emptyMod + protected def farRight: Modifier[HtmlElement] = emptyMod + + def render($data: Signal[List[RowData]]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-200", + children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) + ) + + private def row(d: RowData): HtmlElement = + val data = d.asItem + li( + containerElement( + containerMods(d), + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + data.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + data.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + data.bottomLeft, + data.bottomRight + ) + ), + farRight + ) + ) + ) + +trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) + extends BaseList[RowData]: + + override protected def containerElement: HtmlTag[dom.html.Element] = a + override protected def containerMods( + rowData: RowData + ): Modifier[HtmlElement] = + rowData.navigate + override protected def farRight: Modifier[HtmlElement] = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("w-6 h-6 text-gray-400") + ) diff --git a/ui/src/ui/components/tailwind/list/IconText.scala b/ui/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/src/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/src/ui/components/tailwind/list/ListRow.scala b/ui/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..e5236a2 --- /dev/null +++ b/ui/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom + +object ListRow: + case class ViewModel( + title: String, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + containerElement: HtmlElement = div() + ) + + def apply($m: Signal[ViewModel]): HtmlElement = + li( + child <-- $m.map(m => + m.containerElement.amend( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + m.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + m.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + m.bottomLeft, + m.bottomRight + ) + ), + m.farRight + ) + ) + ) + ) diff --git a/ui/src/ui/components/tailwind/list/PropList.scala b/ui/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/src/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/src/ui/components/tailwind/list/RowNext.scala b/ui/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/src/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..cc44652 --- /dev/null +++ b/ui/src/services/files/components/tailwind/File.scala @@ -0,0 +1,23 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileList.scala b/ui/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileSelector.scala b/ui/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileTable.scala b/ui/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..86e6d85 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..12dd081 --- /dev/null +++ b/ui/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.Macros + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := Macros.size(size), + Icons.outline.user(Macros.size(size - 2)) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := Macros.size(size), + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/ui/components/tailwind/Color.scala b/ui/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..ddcfb25 --- /dev/null +++ b/ui/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,268 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9") + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z") + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14") + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414") + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z") + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + end outline + + object solid: + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z") + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z"), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z"), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z") + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z"), + clipRule("evenodd") + ) + ) + + end solid +end Icons diff --git a/ui/src/ui/components/tailwind/LinkSupport.scala b/ui/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/src/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/src/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/ui/components/tailwind/form/ComboBox.scala b/ui/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/src/ui/components/tailwind/form/Form.scala b/ui/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/src/ui/components/tailwind/form/Form.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/src/ui/components/tailwind/form/FormBody.scala b/ui/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,10 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/src/ui/components/tailwind/form/FormFields.scala b/ui/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/src/ui/components/tailwind/form/FormHeader.scala b/ui/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/src/ui/components/tailwind/form/FormRow.scala b/ui/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/src/ui/components/tailwind/form/FormSection.scala b/ui/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/src/ui/components/tailwind/list/BaseList.scala b/ui/src/ui/components/tailwind/list/BaseList.scala new file mode 100644 index 0000000..04972e8 --- /dev/null +++ b/ui/src/ui/components/tailwind/list/BaseList.scala @@ -0,0 +1,142 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom + +trait ListItem: + def key: String + def title: Modifier[HtmlElement] + def topRight: Modifier[HtmlElement] + def bottomLeft: Modifier[HtmlElement] + def bottomRight: Modifier[HtmlElement] + +trait ListRenderable[Item]: + extension (x: Item) def asItem: ListItem + +trait Navigable[Item]: + extension (x: Item) def navigate: Modifier[HtmlElement] + +object BaseList: + enum Color: + case Green, Yellow, Red + + case class IconText(text: HtmlElement, icon: SvgElement) + case class Tag(text: String, color: Color) + case class Row( + id: String, + title: String, + tag: Tag, + leftProps: List[IconText], + rightProp: IconText + ) + + trait AsRow[Data]: + extension (d: Data) def asRow: Row + + class RowListItem(d: Row) extends ListItem: + + def key: String = d.id + + def title: Modifier[HtmlElement] = d.title + + def topRight: Modifier[HtmlElement] = + inline def colorClass(color: Color): (String, Boolean) = + val c = color.toString.toLowerCase + s"bg-$c-100 text-$c-800" -> (d.tag.color == color) + + inline def colors = Map(Color.values.map(colorClass(_)): _*) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + cls := colors, + d.tag.text + ) + + def bottomLeft: Modifier[HtmlElement] = + div( + cls := "sm:flex", + d.leftProps.zipWithIndex.map { case (i, idx) => + p( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), + cls := "flex items-center text-sm text-gray-500", + i.icon, + i.text + ) + } + ) + + def bottomRight: Modifier[HtmlElement] = + div( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", + d.rightProp.icon, + d.rightProp.text + ) + + object Row: + given asRowRenderable[T: AsRow]: ListRenderable[T] with + extension (d: T) def asItem = new RowListItem(d.asRow) + + end Row + +class BaseList[RowData: ListRenderable]: + + protected def containerElement: HtmlTag[dom.html.Element] = div + protected def containerMods(rowData: RowData): Modifier[HtmlElement] = + emptyMod + protected def farRight: Modifier[HtmlElement] = emptyMod + + def render($data: Signal[List[RowData]]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-200", + children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) + ) + + private def row(d: RowData): HtmlElement = + val data = d.asItem + li( + containerElement( + containerMods(d), + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + data.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + data.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + data.bottomLeft, + data.bottomRight + ) + ), + farRight + ) + ) + ) + +trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) + extends BaseList[RowData]: + + override protected def containerElement: HtmlTag[dom.html.Element] = a + override protected def containerMods( + rowData: RowData + ): Modifier[HtmlElement] = + rowData.navigate + override protected def farRight: Modifier[HtmlElement] = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("w-6 h-6 text-gray-400") + ) diff --git a/ui/src/ui/components/tailwind/list/IconText.scala b/ui/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/src/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/src/ui/components/tailwind/list/ListRow.scala b/ui/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..e5236a2 --- /dev/null +++ b/ui/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom + +object ListRow: + case class ViewModel( + title: String, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + containerElement: HtmlElement = div() + ) + + def apply($m: Signal[ViewModel]): HtmlElement = + li( + child <-- $m.map(m => + m.containerElement.amend( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + m.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + m.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + m.bottomLeft, + m.bottomRight + ) + ), + m.farRight + ) + ) + ) + ) diff --git a/ui/src/ui/components/tailwind/list/PropList.scala b/ui/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/src/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/src/ui/components/tailwind/list/RowNext.scala b/ui/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/src/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/src/ui/components/tailwind/list/RowTag.scala b/ui/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..129abe4 --- /dev/null +++ b/ui/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + case class ViewModel(text: String, color: Color) + def render($m: Signal[ViewModel]): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + cls <-- $m.map(t => colorClass(t.color)), + child.text <-- $m.map(_.text) + ) diff --git a/build.sc b/build.sc index 7f729cc..3f3da4f 100644 --- a/build.sc +++ b/build.sc @@ -14,6 +14,7 @@ "utf8", "-deprecation", "-explain-types", + "-explain", "-feature", "-language:experimental.macros", "-language:higherKinds", diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala deleted file mode 100644 index 09f6c07..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/File.scala +++ /dev/null @@ -1,3 +0,0 @@ -package works.iterative.services.files - -case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala deleted file mode 100644 index 46633a5..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,25 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Renderable - -given Renderable[File] with - extension (m: File) - def toHtml: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid - .paperclip() - .amend(svg.cls := "flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala deleted file mode 100644 index ac59db8..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons -import io.laminext.syntax.core.{*, given} - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.toHtml) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 2e8f690..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala +++ /dev/null @@ -1,81 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.{*, given} - -object FilePicker: - sealed trait Event - sealed trait DoneEvent extends Event - case class SelectionUpdated(files: Set[File]) extends DoneEvent - case object SelectionCancelled extends DoneEvent - case object AvailableFilesRequested extends Event - - def apply( - currentFiles: Signal[List[File]], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val (updatesStream, updatesObserver) = EventStream.withObserver[Event] - val selectorOpen = Var[Boolean](false) - - // This sequence tricks browser into displaying modal content centered - // Inspired by modal in headless ui playground - // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 - inline def browserCenteringModalTrick: Modifier[HtmlElement] = - Seq[Modifier[HtmlElement]]( - span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), - "​" // Zero width space - ) - - inline def overlay: Modifier[HtmlElement] = - // Page overlay - /* TODO: transition - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - */ - div( - div( - onClick.mapTo(false) --> selectorOpen.writer, - cls("fixed inset-0 transition-opacity"), - div(cls("absolute inset-0 bg-gray-500 opacity-75")) - ) - ) - - inline def modalSelector: HtmlElement = - div( - cls("fixed inset-0 z-10 overflow-y-auto"), - div( - cls("text-center sm:block sm:p-0"), - overlay, - browserCenteringModalTrick, - child <-- currentFiles.map( - FileSelector(_, availableFilesStream)(updatesObserver) - ) - ) - ) - - div( - updatesStream --> selectionUpdates, - updatesStream.collect { case _: DoneEvent => - false - } --> selectorOpen.writer, - child.maybe <-- selectorOpen.signal.map(isOpen => - if isOpen then Some(modalSelector) else None - ), - div( - cls("flex flex-col space-y-5"), - child.maybe <-- currentFiles.map(files => - if files.isEmpty then None else Some(FileList(files)) - ), - button( - tpe := "button", - cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - "Zvolit soubory", - onClick.mapTo(true) --> selectorOpen.writer - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala deleted file mode 100644 index b43e07a..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,74 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons -import works.iterative.ui.components.tailwind.Loading -import io.laminext.syntax.core.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object FileSelector: - import FilePicker._ - - def apply( - initialFiles: List[File], - availableFilesStream: EventStream[List[File]] - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - val availableFiles = availableFilesStream.startWithNone - // Request the files to display - selectionUpdates.onNext(AvailableFilesRequested) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" - ), - role("dialog"), - customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), - aria.labelledBy("modal-headline"), - div( - cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), - div( - cls("sm:flex sm:items-start"), - div( - cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), - h3( - cls("text-lg font-medium leading-6 text-gray-900"), - idAttr("modal-headline"), - "Výběr souborů" - ) - ) - ), - child <-- availableFiles - .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) - .map(_.getOrElse(Loading)) - ), - div( - cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), - span( - cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" - ), - "Potvrdit", - composeEvents(onClick)( - _.sample(selectedFiles) - .map(SelectionUpdated(_)) - ) --> selectionUpdates - ) - ), - span( - cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), - button( - typ("button"), - cls( - "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" - ), - "Zrušit", - onClick.mapTo(SelectionCancelled) --> selectionUpdates - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala deleted file mode 100644 index aa11b2b..0000000 --- a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,91 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import works.iterative.ui.components.tailwind.Icons - -def FileTable( - files: Signal[List[File]], - selectedFiles: Var[Set[File]] -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - - def headerRow: HtmlElement = - val col = scope("col") - val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) - val textH = cls( - "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" - ) - tr( - th(baseM, span(cls("sr-only"), "Vybrat")), - th(baseM, textH, "Název"), - th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - f: File, - idx: Int, - selected: Boolean - )(toggleSelection: Observer[Unit]): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection, - cls(if selected then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), - span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection - ), - td( - baseC, - cls("text-right font-medium"), - a( - href(f.url), - target("_blank"), - cls( - "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" - ), - Icons.outline.`external-link`(), - "Otevřít" - ) - ) - ) - - div( - cls("flex flex-col"), - div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), - div( - cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), - div( - cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), - table( - cls("min-w-full divide-y divide-gray-200"), - thead( - cls("bg-gray-50"), - headerRow - ), - tbody( - children <-- files - .map(_.zipWithIndex) - .combineWithFn(selectedFiles)((f, sel) => - f.map((file, idx) => - val active = sel.contains(file) - tableRow(file, idx, active)( - selectedFiles.writer - .contramap(_ => if active then sel - file else sel + file) - ) - ) - ) - ) - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala deleted file mode 100644 index b8da1f9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,35 +0,0 @@ -package works.iterative.ui.components.tailwind - -import CustomAttrs.ariaHidden -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import works.iterative.ui.components.tailwind.Macros - -// TODO: render icon or picture based on img signal -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder(size: Int): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := Macros.size(size), - Icons.outline.user(size - 2) - ) - - inline def avatarImage(size: Int): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := Macros.size(size), - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(size))) - - inline def avatar(size: Int): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(size), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala +++ /dev/null @@ -1,77 +0,0 @@ -package works.iterative.ui.components.tailwind - -enum ColorWeight(value: String): - inline def toCSS: String = value - // To be used for black and white until - // we support better mechanism via extension methods - case w__ extends ColorWeight("") - case w50 extends ColorWeight("50") - case w100 extends ColorWeight("100") - case w200 extends ColorWeight("200") - case w300 extends ColorWeight("300") - case w400 extends ColorWeight("400") - case w500 extends ColorWeight("500") - case w600 extends ColorWeight("600") - case w700 extends ColorWeight("700") - case w800 extends ColorWeight("800") - case w900 extends ColorWeight("900") - -enum Color(name: String): - import ColorWeight._ - - inline def toCSSNoColorWeight(prefix: String): String = - s"${prefix}-${name}" - inline def toCSSWithColorWeight( - prefix: String, - weight: ColorWeight - ): String = - s"${prefix}-${name}-${weight.toCSS}" - inline def toCSS(prefix: String)(weight: ColorWeight): String = - weight match { - case `w__` => toCSSNoColorWeight(prefix) - case _ => toCSSWithColorWeight(prefix, weight) - } - inline def bg: ColorWeight => String = toCSS("bg")(_) - inline def text: ColorWeight => String = toCSS("text")(_) - inline def decoration: ColorWeight => String = toCSS("decoration")(_) - inline def border: ColorWeight => String = toCSS("border")(_) - inline def outline: ColorWeight => String = toCSS("outline")(_) - inline def divide: ColorWeight => String = toCSS("divide")(_) - inline def ring: ColorWeight => String = toCSS("ring")(_) - inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) - inline def shadow: ColorWeight => String = toCSS("shadow")(_) - inline def accent: ColorWeight => String = toCSS("accent")(_) - - // TODO: change the "stupid" methods to extension methods - // that will keep the invariants in comments lower - case current extends Color("current") - case inherit extends Color("inherit") - // Not present in for all methods - case transp extends Color("transparent") - // Seen in accent, not preset otherwise - case auto extends Color("auto") - // Black and white do not have weight - case black extends Color("black") - case white extends Color("white") - case slate extends Color("slate") - case gray extends Color("gray") - case zinc extends Color("zinc") - case neutral extends Color("neutral") - case stone extends Color("stone") - case red extends Color("red") - case orange extends Color("orange") - case amber extends Color("amber") - case yellow extends Color("yellow") - case lime extends Color("lime") - case green extends Color("green") - case emerald extends Color("emerald") - case teal extends Color("teal") - case cyan extends Color("cyan") - case sky extends Color("sky") - case blue extends Color("blue") - case indigo extends Color("indigo") - case violet extends Color("violet") - case purple extends Color("purple") - case fuchsia extends Color("fuchsia") - case pink extends Color("pink") - case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.generic.codecs.StringAsIsCodec -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec - -object CustomAttrs { - // Made a pull request to add aria-current to scala-dom-types, remove after - val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) - val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - - val datetime = customHtmlAttr("datetime", StringAsIsCodec) - - object svg { - import com.raquo.laminar.api.L.svg.{*, given} - val ariaHidden = - customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) - } -} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala +++ /dev/null @@ -1,28 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -object Display: - - enum Breakpoint: - case sm, md, lg, xl, `2xl` - - enum DisplayClass: - case block, `inline-block`, `inline`, flex, `inline-flex`, table, - `inline-table`, `table-caption` - - object ShowUpFrom: - inline def apply( - br: Breakpoint, - dc: DisplayClass = DisplayClass.block - ): HtmlElement = - div( - cls := "hidden", - cls := s"${br}:${dc}" - ) - - object HideUpTo: - inline def apply(br: Breakpoint): HtmlElement = - div( - cls := s"${br}:hidden" - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala deleted file mode 100644 index b49255d..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,271 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec -import com.raquo.domtypes.generic.defs.attrs.AriaAttrs -import com.raquo.laminar.api.L.svg.{*, given} -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import works.iterative.ui.components.tailwind.Macros - -// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site -object Icons: - object aria: - inline def hidden = CustomAttrs.svg.ariaHidden - - object outline: - val defaultSize: Int = 6 - - inline def bell(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" - ) - ) - - inline def `check-circle`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" - ) - ) - - inline def `document-add`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" - ) - ) - - inline def `external-link`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - ) - ) - - inline def menu(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M4 6h16M4 12h16M4 18h16" - ) - ) - - inline def `status-offline`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" - ) - ) - - inline def user(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - fill := "none", - stroke := "currentColor", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" - ) - ) - - inline def x(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - fill := "none", - viewBox := "0 0 24 24", - stroke := "currentColor", - aria.hidden := true, - path( - strokeLineCap := "round", - strokeLineJoin := "round", - strokeWidth := "2", - d := "M6 18L18 6M6 6l12 12" - ) - ) - - end outline - - object solid: - val defaultSize: Int = 5 - - inline def users(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" - ) - ) - - inline def `location-marker`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", - clipRule := "evenodd" - ) - ) - - inline def calendar(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", - clipRule := "evenodd" - ) - ) - - inline def `chevron-right`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", - clipRule := "evenodd" - ) - ) - - inline def search(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", - clipRule := "evenodd" - ) - ) - - inline def filter(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", - clipRule := "evenodd" - ) - ) - - inline def `arrow-narrow-left`(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", - clipRule := "evenodd" - ) - ) - - inline def home(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" - ) - ) - - inline def paperclip(size: Int = defaultSize) = - svg( - cls := Macros.size(size), - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - aria.hidden := true, - path( - fillRule := "evenodd", - d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", - clipRule := "evenodd" - ) - ) - - end solid -end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent - -object LinkSupport: - - extension [El <: org.scalajs.dom.EventTarget]( - ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] - ) - def noKeyMod = - ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala +++ /dev/null @@ -1,13 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -// TODO: proper loader -def Loading = - div( - cls := "bg-gray-50 overflow-hidden rounded-lg", - div( - cls := "px-4 py-5 sm:p-6", - "Loading..." - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Macros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind - -import scala.quoted.* - -/** Tailwind uses JIT compiler that needs to find relevant classes in the code - * in a form that is recognizable - eg. "w-10", not ("w-" + "10") - * - * Macros compute the strings during compile time. - */ -object Macros: - - inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } - - private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ - "h-" + $edgeSize + " w-" + $edgeSize - } diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala deleted file mode 100644 index a0fcc5b..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,6 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -trait Renderable[A]: - extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala +++ /dev/null @@ -1,92 +0,0 @@ -package works.iterative.ui.components.tailwind -package form - -import com.raquo.laminar.api.L.{*, given} -import io.laminext.syntax.core.* - -case class ComboBox( - id: String, - options: Signal[List[ComboBox.Option]], - valueUpdates: Observer[List[String]] -) - -object ComboBox: - - extension (m: ComboBox) - def toHtml: HtmlElement = - val isOpen = Var(false) - div( - cls := "relative mt-1", - input( - idAttr := m.id, - tpe := "text", - cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", - role := "combobox", - aria.controls := "options", - aria.expanded := false, - onClick.mapTo(true) --> isOpen.writer - ), - button( - tpe := "button", - cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { - import svg.* - svg( - cls := "h-5 w-5 text-gray-400", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", - clipRule := "evenodd" - ) - ) - } - ), - ul( - cls <-- isOpen.signal.switch("", "hidden"), - cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", - idAttr := "options", - role := "listbox", - children <-- m.options.map(_.map(_.toHtml)) - ) - ) - - case class Option(value: String, active: Boolean) - - object Option: - extension (m: Option) - def toHtml: HtmlElement = - li( - cls := "relative cursor-default select-none py-2 pl-8 pr-4", - cls := (if m.active then "text-white bg-indigo-600" - else "text-gray-900"), - idAttr := "option-0", - role := "option", - tabIndex := -1, - span( - cls := "block truncate", - m.value - ), - if m.active then - span( - cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", - cls := (if m.active then "text-white" else "text-indigo-600"), { - import svg.* - svg( - cls := "h-5 w-5", - xmlns := "http://www.w3.org/2000/svg", - viewBox := "0 0 20 20", - fill := "currentColor", - CustomAttrs.svg.ariaHidden := true, - path( - fillRule := "evenodd", - d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - clipRule := "evenodd" - ) - ) - } - ) - else emptyNode - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Form: - val Body = FormBody - val Section = FormSection - val Row = FormRow - - def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = - form( - cls := "space-y-8 divide-y divide-gray-200", - body, - buttons - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala +++ /dev/null @@ -1,10 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormBody: - def apply(sections: HtmlElement*): HtmlElement = - div( - cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", - sections - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala +++ /dev/null @@ -1,14 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -object FormFields: - def apply( - mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* - ): HtmlElement = - div( - cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", - mods - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -object FormHeader: - case class ViewModel(header: String, description: String) - def apply(m: ViewModel): HtmlElement = - div( - h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} - -case class FormRow(id: String, label: String, content: Modifier[Div]) - -object FormRow: - - extension (m: FormRow) - def toHtml: HtmlElement = - div( - cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", - label( - forId := m.id, - cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", - m.label - ), - div( - cls := "mt-1 sm:mt-0 sm:col-span-2", - m.content - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala +++ /dev/null @@ -1,15 +0,0 @@ -package works.iterative.ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object FormSection: - def apply( - header: HtmlElement, - rows: HtmlElement* - ): HtmlElement = - div( - cls := "space-y-6 sm:space-y-5", - header, - rows - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala deleted file mode 100644 index 66506e9..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala +++ /dev/null @@ -1,142 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.waypoint.Router -import com.raquo.laminar.builders.HtmlTag -import org.scalajs.dom - -trait ListItem: - def key: String - def title: Modifier[HtmlElement] - def topRight: Modifier[HtmlElement] - def bottomLeft: Modifier[HtmlElement] - def bottomRight: Modifier[HtmlElement] - -trait ListRenderable[Item]: - extension (x: Item) def asItem: ListItem - -trait Navigable[Item]: - extension (x: Item) def navigate: Modifier[HtmlElement] - -object BaseList: - enum Color: - case Green, Yellow, Red - - case class IconText(text: HtmlElement, icon: SvgElement) - case class Tag(text: String, color: Color) - case class Row( - id: String, - title: String, - tag: Tag, - leftProps: List[IconText], - rightProp: IconText - ) - - trait AsRow[Data]: - extension (d: Data) def asRow: Row - - class RowListItem(d: Row) extends ListItem: - - def key: String = d.id - - def title: Modifier[HtmlElement] = d.title - - def topRight: Modifier[HtmlElement] = - inline def colorClass(color: Color): (String, Boolean) = - val c = color.toString.toLowerCase - s"bg-$c-100 text-$c-800" -> (d.tag.color == color) - - inline def colors = Map(Color.values.map(colorClass(_)): _*) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls := colors, - d.tag.text - ) - - def bottomLeft: Modifier[HtmlElement] = - div( - cls := "sm:flex", - d.leftProps.zipWithIndex.map { case (i, idx) => - p( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), - cls := "flex items-center text-sm text-gray-500", - i.icon, - i.text - ) - } - ) - - def bottomRight: Modifier[HtmlElement] = - div( - cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", - d.rightProp.icon, - d.rightProp.text - ) - - object Row: - given asRowRenderable[T: AsRow]: ListRenderable[T] with - extension (d: T) def asItem = new RowListItem(d.asRow) - - end Row - -class BaseList[RowData: ListRenderable]: - - protected def containerElement: HtmlTag[dom.html.Element] = div - protected def containerMods(rowData: RowData): Modifier[HtmlElement] = - emptyMod - protected def farRight: Modifier[HtmlElement] = emptyMod - - def render($data: Signal[List[RowData]]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) - ) - - private def row(d: RowData): HtmlElement = - val data = d.asItem - li( - containerElement( - containerMods(d), - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - data.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - data.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - data.bottomLeft, - data.bottomRight - ) - ), - farRight - ) - ) - ) - -trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) - extends BaseList[RowData]: - - override protected def containerElement: HtmlTag[dom.html.Element] = a - override protected def containerMods( - rowData: RowData - ): Modifier[HtmlElement] = - rowData.navigate - override protected def farRight: Modifier[HtmlElement] = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala +++ /dev/null @@ -1,19 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import com.raquo.laminar.builders.HtmlTag - -object IconText: - case class ViewModel(text: HtmlElement, icon: SvgElement) - def render($m: Signal[ViewModel]): HtmlElement = render($m, div) - def render( - $m: Signal[ViewModel], - container: HtmlTag[dom.html.Element] - ): HtmlElement = - container( - cls := "flex items-center text-sm text-gray-500", - child <-- $m.map(_.icon), - child <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index e5236a2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,47 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom - -object ListRow: - case class ViewModel( - title: String, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - containerElement: HtmlElement = div() - ) - - def apply($m: Signal[ViewModel]): HtmlElement = - li( - child <-- $m.map(m => - m.containerElement.amend( - cls := "block hover:bg-gray-50", - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - p( - cls := "text-sm font-medium text-indigo-600 truncate", - m.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - m.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - m.bottomLeft, - m.bottomRight - ) - ), - m.farRight - ) - ) - ) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object PropList: - type ViewModel = List[HtmlElement] - def render($m: Signal[ViewModel]): HtmlElement = - div( - cls := "sm:flex", - children <-- $m.map(_.zipWithIndex.map { case (i, idx) => - i.amend( - cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) - ) - }) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index b53f3f2..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala +++ /dev/null @@ -1,11 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowNext: - def render: HtmlElement = - div( - cls := "flex-shrink-0", - Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala deleted file mode 100644 index 129abe4..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,17 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - case class ViewModel(text: String, color: Color) - def render($m: Signal[ViewModel]): HtmlElement = - inline def colorClass(color: Color): Seq[String] = - import ColorWeight._ - List(color.bg(w100), color.text(w800)) - - p( - cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", - cls <-- $m.map(t => colorClass(t.color)), - child.text <-- $m.map(_.text) - ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index 6059362..0000000 --- a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -class StackedList[Item]: - type ViewModel = List[Item] - def apply( - $m: Signal[ViewModel], - keyF: Item => String - )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = - ul( - role := "list", - cls := "divide-y divide-gray-200", - children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) - ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..cc44652 --- /dev/null +++ b/ui/src/services/files/components/tailwind/File.scala @@ -0,0 +1,23 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileList.scala b/ui/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileSelector.scala b/ui/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/services/files/components/tailwind/FileTable.scala b/ui/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..86e6d85 --- /dev/null +++ b/ui/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`("w-6 h-6"), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..12dd081 --- /dev/null +++ b/ui/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,35 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import works.iterative.ui.components.tailwind.Macros + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := Macros.size(size), + Icons.outline.user(Macros.size(size - 2)) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := Macros.size(size), + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/ui/components/tailwind/Color.scala b/ui/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..ddcfb25 --- /dev/null +++ b/ui/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,268 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.api.L.SvgElement +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + object outline: + + inline def bell(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9") + ) + ) + + inline def `check-circle`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z") + ) + ) + + inline def `document-add`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z") + ) + ) + + inline def `external-link`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14") + ) + ) + + inline def menu(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M4 6h16M4 12h16M4 18h16") + ) + ) + + inline def `status-offline`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414") + ) + ) + + inline def user(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + fill("none"), + stroke("currentColor"), + viewBox("0 0 24 24"), + xmlns("http://www.w3.org/2000/svg"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z") + ) + ) + + inline def x(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + fill("none"), + viewBox("0 0 24 24"), + stroke("currentColor"), + aria.hidden(true), + path( + strokeLineCap("round"), + strokeLineJoin("round"), + strokeWidth("2"), + d("M6 18L18 6M6 6l12 12") + ) + ) + + end outline + + object solid: + + inline def users(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z") + ) + ) + + inline def `location-marker`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"), + clipRule("evenodd") + ) + ) + + inline def calendar(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"), + clipRule("evenodd") + ) + ) + + inline def `chevron-right`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"), + clipRule("evenodd") + ) + ) + + inline def search(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"), + clipRule("evenodd") + ) + ) + + inline def filter(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z"), + clipRule("evenodd") + ) + ) + + inline def `arrow-narrow-left`(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z"), + clipRule("evenodd") + ) + ) + + inline def home(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + d("M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z") + ) + ) + + inline def paperclip(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + xmlns("http://www.w3.org/2000/svg"), + viewBox("0 0 20 20"), + fill("currentColor"), + aria.hidden(true), + path( + fillRule("evenodd"), + d("M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z"), + clipRule("evenodd") + ) + ) + + end solid +end Icons diff --git a/ui/src/ui/components/tailwind/LinkSupport.scala b/ui/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/src/ui/components/tailwind/Macros.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind + +import scala.quoted.* + +/** Tailwind uses JIT compiler that needs to find relevant classes in the code + * in a form that is recognizable - eg. "w-10", not ("w-" + "10") + * + * Macros compute the strings during compile time. + */ +object Macros: + + inline def size(edgeSize: Int): String = ${ sizeImpl('edgeSize) } + + private def sizeImpl(edgeSize: Expr[Int])(using Quotes): Expr[String] = '{ + "h-" + $edgeSize + " w-" + $edgeSize + } diff --git a/ui/src/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/ui/components/tailwind/form/ComboBox.scala b/ui/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/src/ui/components/tailwind/form/Form.scala b/ui/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/src/ui/components/tailwind/form/Form.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/src/ui/components/tailwind/form/FormBody.scala b/ui/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,10 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/src/ui/components/tailwind/form/FormFields.scala b/ui/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/src/ui/components/tailwind/form/FormHeader.scala b/ui/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/src/ui/components/tailwind/form/FormRow.scala b/ui/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/src/ui/components/tailwind/form/FormSection.scala b/ui/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/src/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/src/ui/components/tailwind/list/BaseList.scala b/ui/src/ui/components/tailwind/list/BaseList.scala new file mode 100644 index 0000000..04972e8 --- /dev/null +++ b/ui/src/ui/components/tailwind/list/BaseList.scala @@ -0,0 +1,142 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom + +trait ListItem: + def key: String + def title: Modifier[HtmlElement] + def topRight: Modifier[HtmlElement] + def bottomLeft: Modifier[HtmlElement] + def bottomRight: Modifier[HtmlElement] + +trait ListRenderable[Item]: + extension (x: Item) def asItem: ListItem + +trait Navigable[Item]: + extension (x: Item) def navigate: Modifier[HtmlElement] + +object BaseList: + enum Color: + case Green, Yellow, Red + + case class IconText(text: HtmlElement, icon: SvgElement) + case class Tag(text: String, color: Color) + case class Row( + id: String, + title: String, + tag: Tag, + leftProps: List[IconText], + rightProp: IconText + ) + + trait AsRow[Data]: + extension (d: Data) def asRow: Row + + class RowListItem(d: Row) extends ListItem: + + def key: String = d.id + + def title: Modifier[HtmlElement] = d.title + + def topRight: Modifier[HtmlElement] = + inline def colorClass(color: Color): (String, Boolean) = + val c = color.toString.toLowerCase + s"bg-$c-100 text-$c-800" -> (d.tag.color == color) + + inline def colors = Map(Color.values.map(colorClass(_)): _*) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + cls := colors, + d.tag.text + ) + + def bottomLeft: Modifier[HtmlElement] = + div( + cls := "sm:flex", + d.leftProps.zipWithIndex.map { case (i, idx) => + p( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), + cls := "flex items-center text-sm text-gray-500", + i.icon, + i.text + ) + } + ) + + def bottomRight: Modifier[HtmlElement] = + div( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", + d.rightProp.icon, + d.rightProp.text + ) + + object Row: + given asRowRenderable[T: AsRow]: ListRenderable[T] with + extension (d: T) def asItem = new RowListItem(d.asRow) + + end Row + +class BaseList[RowData: ListRenderable]: + + protected def containerElement: HtmlTag[dom.html.Element] = div + protected def containerMods(rowData: RowData): Modifier[HtmlElement] = + emptyMod + protected def farRight: Modifier[HtmlElement] = emptyMod + + def render($data: Signal[List[RowData]]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-200", + children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) + ) + + private def row(d: RowData): HtmlElement = + val data = d.asItem + li( + containerElement( + containerMods(d), + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + data.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + data.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + data.bottomLeft, + data.bottomRight + ) + ), + farRight + ) + ) + ) + +trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) + extends BaseList[RowData]: + + override protected def containerElement: HtmlTag[dom.html.Element] = a + override protected def containerMods( + rowData: RowData + ): Modifier[HtmlElement] = + rowData.navigate + override protected def farRight: Modifier[HtmlElement] = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("w-6 h-6 text-gray-400") + ) diff --git a/ui/src/ui/components/tailwind/list/IconText.scala b/ui/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/src/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/src/ui/components/tailwind/list/ListRow.scala b/ui/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..e5236a2 --- /dev/null +++ b/ui/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom + +object ListRow: + case class ViewModel( + title: String, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + containerElement: HtmlElement = div() + ) + + def apply($m: Signal[ViewModel]): HtmlElement = + li( + child <-- $m.map(m => + m.containerElement.amend( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + m.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + m.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + m.bottomLeft, + m.bottomRight + ) + ), + m.farRight + ) + ) + ) + ) diff --git a/ui/src/ui/components/tailwind/list/PropList.scala b/ui/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/src/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/src/ui/components/tailwind/list/RowNext.scala b/ui/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/src/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`("h-5 w-5 text-gray-400") + ) diff --git a/ui/src/ui/components/tailwind/list/RowTag.scala b/ui/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..129abe4 --- /dev/null +++ b/ui/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + case class ViewModel(text: String, color: Color) + def render($m: Signal[ViewModel]): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + cls <-- $m.map(t => colorClass(t.color)), + child.text <-- $m.map(_.text) + ) diff --git a/ui/src/ui/components/tailwind/list/StackedList.scala b/ui/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..6059362 --- /dev/null +++ b/ui/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +class StackedList[Item]: + type ViewModel = List[Item] + def apply( + $m: Signal[ViewModel], + keyF: Item => String + )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-200", + children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) + )