diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 4d37f97..0000000 --- a/ui/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +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 - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - span( - cls := "absolute inset-0 shadow-inner rounded-full", - ariaHidden := true - ) - ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 4d37f97..0000000 --- a/ui/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +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 - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - 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 deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 4d37f97..0000000 --- a/ui/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +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 - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - 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 deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/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/ui/components/tailwind/Component.scala b/ui/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 623862d..0000000 --- a/ui/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 4d37f97..0000000 --- a/ui/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +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 - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - 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 deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/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/ui/components/tailwind/Component.scala b/ui/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 623862d..0000000 --- a/ui/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 4d37f97..0000000 --- a/ui/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +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 - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - 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 deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/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/ui/components/tailwind/Component.scala b/ui/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 623862d..0000000 --- a/ui/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/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/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 4d37f97..0000000 --- a/ui/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +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 - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - 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 deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/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/ui/components/tailwind/Component.scala b/ui/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 623862d..0000000 --- a/ui/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/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/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/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/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 56a0708..0000000 --- a/ui/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,315 +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.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - 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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 4d37f97..0000000 --- a/ui/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +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 - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - 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 deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/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/ui/components/tailwind/Component.scala b/ui/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 623862d..0000000 --- a/ui/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/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/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/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/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 56a0708..0000000 --- a/ui/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,315 +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.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - 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 deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 4d37f97..0000000 --- a/ui/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +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 - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - 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 deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/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/ui/components/tailwind/Component.scala b/ui/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 623862d..0000000 --- a/ui/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/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/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/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/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 56a0708..0000000 --- a/ui/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,315 +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.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - 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 deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/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/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 4d37f97..0000000 --- a/ui/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +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 - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - 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 deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/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/ui/components/tailwind/Component.scala b/ui/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 623862d..0000000 --- a/ui/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/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/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/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/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 56a0708..0000000 --- a/ui/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,315 +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.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - 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 deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/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/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/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/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 4d37f97..0000000 --- a/ui/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +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 - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - 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 deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/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/ui/components/tailwind/Component.scala b/ui/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 623862d..0000000 --- a/ui/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/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/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/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/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 56a0708..0000000 --- a/ui/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,315 +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.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - 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 deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/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/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/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/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/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/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index ec87e6b..0000000 --- a/ui/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate - -trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] - given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 4d37f97..0000000 --- a/ui/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +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 - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - 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 deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/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/ui/components/tailwind/Component.scala b/ui/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 623862d..0000000 --- a/ui/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/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/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/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/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 56a0708..0000000 --- a/ui/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,315 +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.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - 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 deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/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/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/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/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/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/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index ec87e6b..0000000 --- a/ui/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate - -trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] - given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) diff --git a/ui/src/ui/components/tailwind/Switch.scala b/ui/src/ui/components/tailwind/Switch.scala deleted file mode 100644 index dd24ed0..0000000 --- a/ui/src/ui/components/tailwind/Switch.scala +++ /dev/null @@ -1,38 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString - -object Switch { - def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = - div( - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- toggle.signal.map(a => - if a then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- toggle.signal.map(a => - if a then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(toggle.signal).map(a => !a) - ) --> toggle.writer - ), - name.map(n => - span( - cls := "ml-3", - idAttr := "active-only-label", - span(cls := "text-sm font-medium text-gray-900", n) - ) - ) - ) -} diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 4d37f97..0000000 --- a/ui/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +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 - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - 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 deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/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/ui/components/tailwind/Component.scala b/ui/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 623862d..0000000 --- a/ui/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/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/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/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/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 56a0708..0000000 --- a/ui/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,315 +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.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - 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 deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/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/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/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/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/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/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index ec87e6b..0000000 --- a/ui/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate - -trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] - given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) diff --git a/ui/src/ui/components/tailwind/Switch.scala b/ui/src/ui/components/tailwind/Switch.scala deleted file mode 100644 index dd24ed0..0000000 --- a/ui/src/ui/components/tailwind/Switch.scala +++ /dev/null @@ -1,38 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString - -object Switch { - def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = - div( - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- toggle.signal.map(a => - if a then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- toggle.signal.map(a => - if a then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(toggle.signal).map(a => !a) - ) --> toggle.writer - ), - name.map(n => - span( - cls := "ml-3", - idAttr := "active-only-label", - span(cls := "text-sm font-medium text-gray-900", n) - ) - ) - ) -} diff --git a/ui/src/ui/components/tailwind/TailwindSupport.scala b/ui/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index 2c7d850..0000000 --- a/ui/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,35 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "bg-red-100", - "bg-amber-100", - "bg-green-100" - ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 4d37f97..0000000 --- a/ui/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +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 - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - 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 deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/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/ui/components/tailwind/Component.scala b/ui/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 623862d..0000000 --- a/ui/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/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/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/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/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 56a0708..0000000 --- a/ui/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,315 +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.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - 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 deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/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/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/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/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/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/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index ec87e6b..0000000 --- a/ui/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate - -trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] - given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) diff --git a/ui/src/ui/components/tailwind/Switch.scala b/ui/src/ui/components/tailwind/Switch.scala deleted file mode 100644 index dd24ed0..0000000 --- a/ui/src/ui/components/tailwind/Switch.scala +++ /dev/null @@ -1,38 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString - -object Switch { - def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = - div( - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- toggle.signal.map(a => - if a then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- toggle.signal.map(a => - if a then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(toggle.signal).map(a => !a) - ) --> toggle.writer - ), - name.map(n => - span( - cls := "ml-3", - idAttr := "active-only-label", - span(cls := "text-sm font-medium text-gray-900", n) - ) - ) - ) -} diff --git a/ui/src/ui/components/tailwind/TailwindSupport.scala b/ui/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index 2c7d850..0000000 --- a/ui/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,35 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "bg-red-100", - "bg-amber-100", - "bg-green-100" - ) diff --git a/ui/src/ui/components/tailwind/TimeUtils.scala b/ui/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 4d37f97..0000000 --- a/ui/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +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 - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - 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 deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/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/ui/components/tailwind/Component.scala b/ui/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 623862d..0000000 --- a/ui/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/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/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/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/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 56a0708..0000000 --- a/ui/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,315 +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.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - 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 deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/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/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/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/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/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/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index ec87e6b..0000000 --- a/ui/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate - -trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] - given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) diff --git a/ui/src/ui/components/tailwind/Switch.scala b/ui/src/ui/components/tailwind/Switch.scala deleted file mode 100644 index dd24ed0..0000000 --- a/ui/src/ui/components/tailwind/Switch.scala +++ /dev/null @@ -1,38 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString - -object Switch { - def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = - div( - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- toggle.signal.map(a => - if a then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- toggle.signal.map(a => - if a then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(toggle.signal).map(a => !a) - ) --> toggle.writer - ), - name.map(n => - span( - cls := "ml-3", - idAttr := "active-only-label", - span(cls := "text-sm font-medium text-gray-900", n) - ) - ) - ) -} diff --git a/ui/src/ui/components/tailwind/TailwindSupport.scala b/ui/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index 2c7d850..0000000 --- a/ui/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,35 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "bg-red-100", - "bg-amber-100", - "bg-green-100" - ) diff --git a/ui/src/ui/components/tailwind/TimeUtils.scala b/ui/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 61ed625..0000000 --- a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - extension (v: V) def labeled(n: UIString): OptionalLabeledValue - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - extension (v: Option[V]) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] with - extension (d: LeftAlignedInCard[A]) - def element: HtmlElement = - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 4d37f97..0000000 --- a/ui/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +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 - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - 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 deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/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/ui/components/tailwind/Component.scala b/ui/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 623862d..0000000 --- a/ui/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/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/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/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/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 56a0708..0000000 --- a/ui/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,315 +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.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - 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 deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/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/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/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/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/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/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index ec87e6b..0000000 --- a/ui/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate - -trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] - given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) diff --git a/ui/src/ui/components/tailwind/Switch.scala b/ui/src/ui/components/tailwind/Switch.scala deleted file mode 100644 index dd24ed0..0000000 --- a/ui/src/ui/components/tailwind/Switch.scala +++ /dev/null @@ -1,38 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString - -object Switch { - def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = - div( - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- toggle.signal.map(a => - if a then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- toggle.signal.map(a => - if a then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(toggle.signal).map(a => !a) - ) --> toggle.writer - ), - name.map(n => - span( - cls := "ml-3", - idAttr := "active-only-label", - span(cls := "text-sm font-medium text-gray-900", n) - ) - ) - ) -} diff --git a/ui/src/ui/components/tailwind/TailwindSupport.scala b/ui/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index 2c7d850..0000000 --- a/ui/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,35 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "bg-red-100", - "bg-amber-100", - "bg-green-100" - ) diff --git a/ui/src/ui/components/tailwind/TimeUtils.scala b/ui/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 61ed625..0000000 --- a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - extension (v: V) def labeled(n: UIString): OptionalLabeledValue - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - extension (v: Option[V]) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] with - extension (d: LeftAlignedInCard[A]) - def element: HtmlElement = - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/src/ui/components/tailwind/form/ActionButtons.scala b/ui/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 49af7c0..0000000 --- a/ui/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.HtmlComponent - -case class ActionButton[A]( - title: UIString, - action: A -) - -// buttons to attach under for or detail cards -case class ActionButtons[A]( - actions: List[ActionButton[A]] -) - -object ActionButtons: - class Component[A](actions: Observer[A]) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - extension (v: ActionButtons[A]) - def element: Div = - div( - cls("flex justify-end"), - v.actions.zipWithIndex.map { - case (ActionButton(title, action), idx) => - button( - tpe("button"), - cls(if idx == 0 then "" else "ml-3"), - cls( - "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - title, - onClick.mapTo(action) --> actions - ) - } - ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 4d37f97..0000000 --- a/ui/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +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 - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - 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 deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/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/ui/components/tailwind/Component.scala b/ui/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 623862d..0000000 --- a/ui/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/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/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/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/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 56a0708..0000000 --- a/ui/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,315 +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.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - 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 deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/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/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/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/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/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/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index ec87e6b..0000000 --- a/ui/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate - -trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] - given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) diff --git a/ui/src/ui/components/tailwind/Switch.scala b/ui/src/ui/components/tailwind/Switch.scala deleted file mode 100644 index dd24ed0..0000000 --- a/ui/src/ui/components/tailwind/Switch.scala +++ /dev/null @@ -1,38 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString - -object Switch { - def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = - div( - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- toggle.signal.map(a => - if a then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- toggle.signal.map(a => - if a then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(toggle.signal).map(a => !a) - ) --> toggle.writer - ), - name.map(n => - span( - cls := "ml-3", - idAttr := "active-only-label", - span(cls := "text-sm font-medium text-gray-900", n) - ) - ) - ) -} diff --git a/ui/src/ui/components/tailwind/TailwindSupport.scala b/ui/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index 2c7d850..0000000 --- a/ui/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,35 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "bg-red-100", - "bg-amber-100", - "bg-green-100" - ) diff --git a/ui/src/ui/components/tailwind/TimeUtils.scala b/ui/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 61ed625..0000000 --- a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - extension (v: V) def labeled(n: UIString): OptionalLabeledValue - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - extension (v: Option[V]) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] with - extension (d: LeftAlignedInCard[A]) - def element: HtmlElement = - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/src/ui/components/tailwind/form/ActionButtons.scala b/ui/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 49af7c0..0000000 --- a/ui/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.HtmlComponent - -case class ActionButton[A]( - title: UIString, - action: A -) - -// buttons to attach under for or detail cards -case class ActionButtons[A]( - actions: List[ActionButton[A]] -) - -object ActionButtons: - class Component[A](actions: Observer[A]) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - extension (v: ActionButtons[A]) - def element: Div = - div( - cls("flex justify-end"), - v.actions.zipWithIndex.map { - case (ActionButton(title, action), idx) => - button( - tpe("button"), - cls(if idx == 0 then "" else "ml-3"), - cls( - "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - title, - onClick.mapTo(action) --> actions - ) - } - ) diff --git a/ui/src/ui/components/tailwind/form/ComboBox.scala b/ui/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 4d37f97..0000000 --- a/ui/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +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 - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - 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 deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/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/ui/components/tailwind/Component.scala b/ui/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 623862d..0000000 --- a/ui/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/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/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/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/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 56a0708..0000000 --- a/ui/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,315 +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.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - 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 deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/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/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/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/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/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/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index ec87e6b..0000000 --- a/ui/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate - -trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] - given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) diff --git a/ui/src/ui/components/tailwind/Switch.scala b/ui/src/ui/components/tailwind/Switch.scala deleted file mode 100644 index dd24ed0..0000000 --- a/ui/src/ui/components/tailwind/Switch.scala +++ /dev/null @@ -1,38 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString - -object Switch { - def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = - div( - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- toggle.signal.map(a => - if a then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- toggle.signal.map(a => - if a then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(toggle.signal).map(a => !a) - ) --> toggle.writer - ), - name.map(n => - span( - cls := "ml-3", - idAttr := "active-only-label", - span(cls := "text-sm font-medium text-gray-900", n) - ) - ) - ) -} diff --git a/ui/src/ui/components/tailwind/TailwindSupport.scala b/ui/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index 2c7d850..0000000 --- a/ui/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,35 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "bg-red-100", - "bg-amber-100", - "bg-green-100" - ) diff --git a/ui/src/ui/components/tailwind/TimeUtils.scala b/ui/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 61ed625..0000000 --- a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - extension (v: V) def labeled(n: UIString): OptionalLabeledValue - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - extension (v: Option[V]) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] with - extension (d: LeftAlignedInCard[A]) - def element: HtmlElement = - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/src/ui/components/tailwind/form/ActionButtons.scala b/ui/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 49af7c0..0000000 --- a/ui/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.HtmlComponent - -case class ActionButton[A]( - title: UIString, - action: A -) - -// buttons to attach under for or detail cards -case class ActionButtons[A]( - actions: List[ActionButton[A]] -) - -object ActionButtons: - class Component[A](actions: Observer[A]) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - extension (v: ActionButtons[A]) - def element: Div = - div( - cls("flex justify-end"), - v.actions.zipWithIndex.map { - case (ActionButton(title, action), idx) => - button( - tpe("button"), - cls(if idx == 0 then "" else "ml-3"), - cls( - "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - title, - onClick.mapTo(action) --> actions - ) - } - ) diff --git a/ui/src/ui/components/tailwind/form/ComboBox.scala b/ui/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/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/ui/components/tailwind/form/Form.scala b/ui/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 4d37f97..0000000 --- a/ui/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +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 - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - 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 deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/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/ui/components/tailwind/Component.scala b/ui/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 623862d..0000000 --- a/ui/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/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/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/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/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 56a0708..0000000 --- a/ui/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,315 +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.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - 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 deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/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/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/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/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/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/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index ec87e6b..0000000 --- a/ui/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate - -trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] - given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) diff --git a/ui/src/ui/components/tailwind/Switch.scala b/ui/src/ui/components/tailwind/Switch.scala deleted file mode 100644 index dd24ed0..0000000 --- a/ui/src/ui/components/tailwind/Switch.scala +++ /dev/null @@ -1,38 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString - -object Switch { - def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = - div( - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- toggle.signal.map(a => - if a then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- toggle.signal.map(a => - if a then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(toggle.signal).map(a => !a) - ) --> toggle.writer - ), - name.map(n => - span( - cls := "ml-3", - idAttr := "active-only-label", - span(cls := "text-sm font-medium text-gray-900", n) - ) - ) - ) -} diff --git a/ui/src/ui/components/tailwind/TailwindSupport.scala b/ui/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index 2c7d850..0000000 --- a/ui/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,35 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "bg-red-100", - "bg-amber-100", - "bg-green-100" - ) diff --git a/ui/src/ui/components/tailwind/TimeUtils.scala b/ui/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 61ed625..0000000 --- a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - extension (v: V) def labeled(n: UIString): OptionalLabeledValue - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - extension (v: Option[V]) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] with - extension (d: LeftAlignedInCard[A]) - def element: HtmlElement = - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/src/ui/components/tailwind/form/ActionButtons.scala b/ui/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 49af7c0..0000000 --- a/ui/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.HtmlComponent - -case class ActionButton[A]( - title: UIString, - action: A -) - -// buttons to attach under for or detail cards -case class ActionButtons[A]( - actions: List[ActionButton[A]] -) - -object ActionButtons: - class Component[A](actions: Observer[A]) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - extension (v: ActionButtons[A]) - def element: Div = - div( - cls("flex justify-end"), - v.actions.zipWithIndex.map { - case (ActionButton(title, action), idx) => - button( - tpe("button"), - cls(if idx == 0 then "" else "ml-3"), - cls( - "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - title, - onClick.mapTo(action) --> actions - ) - } - ) diff --git a/ui/src/ui/components/tailwind/form/ComboBox.scala b/ui/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/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/ui/components/tailwind/form/Form.scala b/ui/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormBody.scala b/ui/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 4d37f97..0000000 --- a/ui/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +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 - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - 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 deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/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/ui/components/tailwind/Component.scala b/ui/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 623862d..0000000 --- a/ui/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/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/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/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/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 56a0708..0000000 --- a/ui/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,315 +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.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - 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 deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/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/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/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/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/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/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index ec87e6b..0000000 --- a/ui/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate - -trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] - given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) diff --git a/ui/src/ui/components/tailwind/Switch.scala b/ui/src/ui/components/tailwind/Switch.scala deleted file mode 100644 index dd24ed0..0000000 --- a/ui/src/ui/components/tailwind/Switch.scala +++ /dev/null @@ -1,38 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString - -object Switch { - def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = - div( - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- toggle.signal.map(a => - if a then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- toggle.signal.map(a => - if a then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(toggle.signal).map(a => !a) - ) --> toggle.writer - ), - name.map(n => - span( - cls := "ml-3", - idAttr := "active-only-label", - span(cls := "text-sm font-medium text-gray-900", n) - ) - ) - ) -} diff --git a/ui/src/ui/components/tailwind/TailwindSupport.scala b/ui/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index 2c7d850..0000000 --- a/ui/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,35 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "bg-red-100", - "bg-amber-100", - "bg-green-100" - ) diff --git a/ui/src/ui/components/tailwind/TimeUtils.scala b/ui/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 61ed625..0000000 --- a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - extension (v: V) def labeled(n: UIString): OptionalLabeledValue - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - extension (v: Option[V]) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] with - extension (d: LeftAlignedInCard[A]) - def element: HtmlElement = - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/src/ui/components/tailwind/form/ActionButtons.scala b/ui/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 49af7c0..0000000 --- a/ui/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.HtmlComponent - -case class ActionButton[A]( - title: UIString, - action: A -) - -// buttons to attach under for or detail cards -case class ActionButtons[A]( - actions: List[ActionButton[A]] -) - -object ActionButtons: - class Component[A](actions: Observer[A]) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - extension (v: ActionButtons[A]) - def element: Div = - div( - cls("flex justify-end"), - v.actions.zipWithIndex.map { - case (ActionButton(title, action), idx) => - button( - tpe("button"), - cls(if idx == 0 then "" else "ml-3"), - cls( - "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - title, - onClick.mapTo(action) --> actions - ) - } - ) diff --git a/ui/src/ui/components/tailwind/form/ComboBox.scala b/ui/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/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/ui/components/tailwind/form/Form.scala b/ui/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormBody.scala b/ui/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormFields.scala b/ui/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 4d37f97..0000000 --- a/ui/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +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 - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - 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 deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/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/ui/components/tailwind/Component.scala b/ui/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 623862d..0000000 --- a/ui/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/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/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/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/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 56a0708..0000000 --- a/ui/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,315 +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.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - 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 deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/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/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/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/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/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/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index ec87e6b..0000000 --- a/ui/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate - -trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] - given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) diff --git a/ui/src/ui/components/tailwind/Switch.scala b/ui/src/ui/components/tailwind/Switch.scala deleted file mode 100644 index dd24ed0..0000000 --- a/ui/src/ui/components/tailwind/Switch.scala +++ /dev/null @@ -1,38 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString - -object Switch { - def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = - div( - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- toggle.signal.map(a => - if a then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- toggle.signal.map(a => - if a then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(toggle.signal).map(a => !a) - ) --> toggle.writer - ), - name.map(n => - span( - cls := "ml-3", - idAttr := "active-only-label", - span(cls := "text-sm font-medium text-gray-900", n) - ) - ) - ) -} diff --git a/ui/src/ui/components/tailwind/TailwindSupport.scala b/ui/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index 2c7d850..0000000 --- a/ui/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,35 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "bg-red-100", - "bg-amber-100", - "bg-green-100" - ) diff --git a/ui/src/ui/components/tailwind/TimeUtils.scala b/ui/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 61ed625..0000000 --- a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - extension (v: V) def labeled(n: UIString): OptionalLabeledValue - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - extension (v: Option[V]) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] with - extension (d: LeftAlignedInCard[A]) - def element: HtmlElement = - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/src/ui/components/tailwind/form/ActionButtons.scala b/ui/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 49af7c0..0000000 --- a/ui/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.HtmlComponent - -case class ActionButton[A]( - title: UIString, - action: A -) - -// buttons to attach under for or detail cards -case class ActionButtons[A]( - actions: List[ActionButton[A]] -) - -object ActionButtons: - class Component[A](actions: Observer[A]) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - extension (v: ActionButtons[A]) - def element: Div = - div( - cls("flex justify-end"), - v.actions.zipWithIndex.map { - case (ActionButton(title, action), idx) => - button( - tpe("button"), - cls(if idx == 0 then "" else "ml-3"), - cls( - "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - title, - onClick.mapTo(action) --> actions - ) - } - ) diff --git a/ui/src/ui/components/tailwind/form/ComboBox.scala b/ui/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/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/ui/components/tailwind/form/Form.scala b/ui/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormBody.scala b/ui/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormFields.scala b/ui/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormHeader.scala b/ui/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 4d37f97..0000000 --- a/ui/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +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 - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - 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 deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/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/ui/components/tailwind/Component.scala b/ui/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 623862d..0000000 --- a/ui/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/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/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/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/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 56a0708..0000000 --- a/ui/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,315 +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.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - 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 deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/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/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/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/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/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/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index ec87e6b..0000000 --- a/ui/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate - -trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] - given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) diff --git a/ui/src/ui/components/tailwind/Switch.scala b/ui/src/ui/components/tailwind/Switch.scala deleted file mode 100644 index dd24ed0..0000000 --- a/ui/src/ui/components/tailwind/Switch.scala +++ /dev/null @@ -1,38 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString - -object Switch { - def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = - div( - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- toggle.signal.map(a => - if a then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- toggle.signal.map(a => - if a then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(toggle.signal).map(a => !a) - ) --> toggle.writer - ), - name.map(n => - span( - cls := "ml-3", - idAttr := "active-only-label", - span(cls := "text-sm font-medium text-gray-900", n) - ) - ) - ) -} diff --git a/ui/src/ui/components/tailwind/TailwindSupport.scala b/ui/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index 2c7d850..0000000 --- a/ui/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,35 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "bg-red-100", - "bg-amber-100", - "bg-green-100" - ) diff --git a/ui/src/ui/components/tailwind/TimeUtils.scala b/ui/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 61ed625..0000000 --- a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - extension (v: V) def labeled(n: UIString): OptionalLabeledValue - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - extension (v: Option[V]) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] with - extension (d: LeftAlignedInCard[A]) - def element: HtmlElement = - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/src/ui/components/tailwind/form/ActionButtons.scala b/ui/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 49af7c0..0000000 --- a/ui/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.HtmlComponent - -case class ActionButton[A]( - title: UIString, - action: A -) - -// buttons to attach under for or detail cards -case class ActionButtons[A]( - actions: List[ActionButton[A]] -) - -object ActionButtons: - class Component[A](actions: Observer[A]) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - extension (v: ActionButtons[A]) - def element: Div = - div( - cls("flex justify-end"), - v.actions.zipWithIndex.map { - case (ActionButton(title, action), idx) => - button( - tpe("button"), - cls(if idx == 0 then "" else "ml-3"), - cls( - "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - title, - onClick.mapTo(action) --> actions - ) - } - ) diff --git a/ui/src/ui/components/tailwind/form/ComboBox.scala b/ui/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/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/ui/components/tailwind/form/Form.scala b/ui/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormBody.scala b/ui/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormFields.scala b/ui/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormHeader.scala b/ui/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormRow.scala b/ui/src/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 4d37f97..0000000 --- a/ui/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +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 - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - 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 deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/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/ui/components/tailwind/Component.scala b/ui/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 623862d..0000000 --- a/ui/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/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/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/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/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 56a0708..0000000 --- a/ui/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,315 +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.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - 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 deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/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/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/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/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/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/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index ec87e6b..0000000 --- a/ui/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate - -trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] - given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) diff --git a/ui/src/ui/components/tailwind/Switch.scala b/ui/src/ui/components/tailwind/Switch.scala deleted file mode 100644 index dd24ed0..0000000 --- a/ui/src/ui/components/tailwind/Switch.scala +++ /dev/null @@ -1,38 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString - -object Switch { - def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = - div( - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- toggle.signal.map(a => - if a then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- toggle.signal.map(a => - if a then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(toggle.signal).map(a => !a) - ) --> toggle.writer - ), - name.map(n => - span( - cls := "ml-3", - idAttr := "active-only-label", - span(cls := "text-sm font-medium text-gray-900", n) - ) - ) - ) -} diff --git a/ui/src/ui/components/tailwind/TailwindSupport.scala b/ui/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index 2c7d850..0000000 --- a/ui/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,35 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "bg-red-100", - "bg-amber-100", - "bg-green-100" - ) diff --git a/ui/src/ui/components/tailwind/TimeUtils.scala b/ui/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 61ed625..0000000 --- a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - extension (v: V) def labeled(n: UIString): OptionalLabeledValue - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - extension (v: Option[V]) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] with - extension (d: LeftAlignedInCard[A]) - def element: HtmlElement = - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/src/ui/components/tailwind/form/ActionButtons.scala b/ui/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 49af7c0..0000000 --- a/ui/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.HtmlComponent - -case class ActionButton[A]( - title: UIString, - action: A -) - -// buttons to attach under for or detail cards -case class ActionButtons[A]( - actions: List[ActionButton[A]] -) - -object ActionButtons: - class Component[A](actions: Observer[A]) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - extension (v: ActionButtons[A]) - def element: Div = - div( - cls("flex justify-end"), - v.actions.zipWithIndex.map { - case (ActionButton(title, action), idx) => - button( - tpe("button"), - cls(if idx == 0 then "" else "ml-3"), - cls( - "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - title, - onClick.mapTo(action) --> actions - ) - } - ) diff --git a/ui/src/ui/components/tailwind/form/ComboBox.scala b/ui/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/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/ui/components/tailwind/form/Form.scala b/ui/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormBody.scala b/ui/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormFields.scala b/ui/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormHeader.scala b/ui/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormRow.scala b/ui/src/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormSection.scala b/ui/src/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 4d37f97..0000000 --- a/ui/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +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 - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - 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 deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/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/ui/components/tailwind/Component.scala b/ui/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 623862d..0000000 --- a/ui/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/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/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/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/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 56a0708..0000000 --- a/ui/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,315 +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.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - 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 deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/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/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/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/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/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/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index ec87e6b..0000000 --- a/ui/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate - -trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] - given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) diff --git a/ui/src/ui/components/tailwind/Switch.scala b/ui/src/ui/components/tailwind/Switch.scala deleted file mode 100644 index dd24ed0..0000000 --- a/ui/src/ui/components/tailwind/Switch.scala +++ /dev/null @@ -1,38 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString - -object Switch { - def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = - div( - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- toggle.signal.map(a => - if a then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- toggle.signal.map(a => - if a then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(toggle.signal).map(a => !a) - ) --> toggle.writer - ), - name.map(n => - span( - cls := "ml-3", - idAttr := "active-only-label", - span(cls := "text-sm font-medium text-gray-900", n) - ) - ) - ) -} diff --git a/ui/src/ui/components/tailwind/TailwindSupport.scala b/ui/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index 2c7d850..0000000 --- a/ui/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,35 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "bg-red-100", - "bg-amber-100", - "bg-green-100" - ) diff --git a/ui/src/ui/components/tailwind/TimeUtils.scala b/ui/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 61ed625..0000000 --- a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - extension (v: V) def labeled(n: UIString): OptionalLabeledValue - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - extension (v: Option[V]) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] with - extension (d: LeftAlignedInCard[A]) - def element: HtmlElement = - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/src/ui/components/tailwind/form/ActionButtons.scala b/ui/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 49af7c0..0000000 --- a/ui/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.HtmlComponent - -case class ActionButton[A]( - title: UIString, - action: A -) - -// buttons to attach under for or detail cards -case class ActionButtons[A]( - actions: List[ActionButton[A]] -) - -object ActionButtons: - class Component[A](actions: Observer[A]) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - extension (v: ActionButtons[A]) - def element: Div = - div( - cls("flex justify-end"), - v.actions.zipWithIndex.map { - case (ActionButton(title, action), idx) => - button( - tpe("button"), - cls(if idx == 0 then "" else "ml-3"), - cls( - "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - title, - onClick.mapTo(action) --> actions - ) - } - ) diff --git a/ui/src/ui/components/tailwind/form/ComboBox.scala b/ui/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/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/ui/components/tailwind/form/Form.scala b/ui/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormBody.scala b/ui/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormFields.scala b/ui/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormHeader.scala b/ui/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormRow.scala b/ui/src/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormSection.scala b/ui/src/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/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/ui/components/tailwind/list/IconText.scala b/ui/src/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 4d37f97..0000000 --- a/ui/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +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 - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - 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 deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/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/ui/components/tailwind/Component.scala b/ui/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 623862d..0000000 --- a/ui/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/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/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/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/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 56a0708..0000000 --- a/ui/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,315 +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.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - 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 deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/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/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/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/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/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/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index ec87e6b..0000000 --- a/ui/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate - -trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] - given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) diff --git a/ui/src/ui/components/tailwind/Switch.scala b/ui/src/ui/components/tailwind/Switch.scala deleted file mode 100644 index dd24ed0..0000000 --- a/ui/src/ui/components/tailwind/Switch.scala +++ /dev/null @@ -1,38 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString - -object Switch { - def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = - div( - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- toggle.signal.map(a => - if a then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- toggle.signal.map(a => - if a then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(toggle.signal).map(a => !a) - ) --> toggle.writer - ), - name.map(n => - span( - cls := "ml-3", - idAttr := "active-only-label", - span(cls := "text-sm font-medium text-gray-900", n) - ) - ) - ) -} diff --git a/ui/src/ui/components/tailwind/TailwindSupport.scala b/ui/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index 2c7d850..0000000 --- a/ui/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,35 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "bg-red-100", - "bg-amber-100", - "bg-green-100" - ) diff --git a/ui/src/ui/components/tailwind/TimeUtils.scala b/ui/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 61ed625..0000000 --- a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - extension (v: V) def labeled(n: UIString): OptionalLabeledValue - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - extension (v: Option[V]) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] with - extension (d: LeftAlignedInCard[A]) - def element: HtmlElement = - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/src/ui/components/tailwind/form/ActionButtons.scala b/ui/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 49af7c0..0000000 --- a/ui/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.HtmlComponent - -case class ActionButton[A]( - title: UIString, - action: A -) - -// buttons to attach under for or detail cards -case class ActionButtons[A]( - actions: List[ActionButton[A]] -) - -object ActionButtons: - class Component[A](actions: Observer[A]) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - extension (v: ActionButtons[A]) - def element: Div = - div( - cls("flex justify-end"), - v.actions.zipWithIndex.map { - case (ActionButton(title, action), idx) => - button( - tpe("button"), - cls(if idx == 0 then "" else "ml-3"), - cls( - "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - title, - onClick.mapTo(action) --> actions - ) - } - ) diff --git a/ui/src/ui/components/tailwind/form/ComboBox.scala b/ui/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/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/ui/components/tailwind/form/Form.scala b/ui/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormBody.scala b/ui/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormFields.scala b/ui/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormHeader.scala b/ui/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormRow.scala b/ui/src/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormSection.scala b/ui/src/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/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/ui/components/tailwind/list/IconText.scala b/ui/src/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/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/ui/components/tailwind/list/ListRow.scala b/ui/src/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54a7769..0000000 --- a/ui/src/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +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 -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with - extension (r: ListRow) - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - 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", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = - val c = content - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 4d37f97..0000000 --- a/ui/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +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 - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - 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 deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/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/ui/components/tailwind/Component.scala b/ui/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 623862d..0000000 --- a/ui/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/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/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/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/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 56a0708..0000000 --- a/ui/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,315 +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.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - 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 deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/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/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/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/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/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/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index ec87e6b..0000000 --- a/ui/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate - -trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] - given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) diff --git a/ui/src/ui/components/tailwind/Switch.scala b/ui/src/ui/components/tailwind/Switch.scala deleted file mode 100644 index dd24ed0..0000000 --- a/ui/src/ui/components/tailwind/Switch.scala +++ /dev/null @@ -1,38 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString - -object Switch { - def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = - div( - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- toggle.signal.map(a => - if a then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- toggle.signal.map(a => - if a then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(toggle.signal).map(a => !a) - ) --> toggle.writer - ), - name.map(n => - span( - cls := "ml-3", - idAttr := "active-only-label", - span(cls := "text-sm font-medium text-gray-900", n) - ) - ) - ) -} diff --git a/ui/src/ui/components/tailwind/TailwindSupport.scala b/ui/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index 2c7d850..0000000 --- a/ui/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,35 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "bg-red-100", - "bg-amber-100", - "bg-green-100" - ) diff --git a/ui/src/ui/components/tailwind/TimeUtils.scala b/ui/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 61ed625..0000000 --- a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - extension (v: V) def labeled(n: UIString): OptionalLabeledValue - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - extension (v: Option[V]) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] with - extension (d: LeftAlignedInCard[A]) - def element: HtmlElement = - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/src/ui/components/tailwind/form/ActionButtons.scala b/ui/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 49af7c0..0000000 --- a/ui/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.HtmlComponent - -case class ActionButton[A]( - title: UIString, - action: A -) - -// buttons to attach under for or detail cards -case class ActionButtons[A]( - actions: List[ActionButton[A]] -) - -object ActionButtons: - class Component[A](actions: Observer[A]) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - extension (v: ActionButtons[A]) - def element: Div = - div( - cls("flex justify-end"), - v.actions.zipWithIndex.map { - case (ActionButton(title, action), idx) => - button( - tpe("button"), - cls(if idx == 0 then "" else "ml-3"), - cls( - "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - title, - onClick.mapTo(action) --> actions - ) - } - ) diff --git a/ui/src/ui/components/tailwind/form/ComboBox.scala b/ui/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/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/ui/components/tailwind/form/Form.scala b/ui/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormBody.scala b/ui/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormFields.scala b/ui/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormHeader.scala b/ui/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormRow.scala b/ui/src/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormSection.scala b/ui/src/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/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/ui/components/tailwind/list/IconText.scala b/ui/src/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/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/ui/components/tailwind/list/ListRow.scala b/ui/src/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54a7769..0000000 --- a/ui/src/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +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 -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with - extension (r: ListRow) - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - 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", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = - val c = content - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) diff --git a/ui/src/ui/components/tailwind/list/PropList.scala b/ui/src/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/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/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 4d37f97..0000000 --- a/ui/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +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 - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - 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 deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/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/ui/components/tailwind/Component.scala b/ui/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 623862d..0000000 --- a/ui/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/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/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/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/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 56a0708..0000000 --- a/ui/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,315 +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.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - 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 deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/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/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/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/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/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/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index ec87e6b..0000000 --- a/ui/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate - -trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] - given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) diff --git a/ui/src/ui/components/tailwind/Switch.scala b/ui/src/ui/components/tailwind/Switch.scala deleted file mode 100644 index dd24ed0..0000000 --- a/ui/src/ui/components/tailwind/Switch.scala +++ /dev/null @@ -1,38 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString - -object Switch { - def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = - div( - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- toggle.signal.map(a => - if a then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- toggle.signal.map(a => - if a then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(toggle.signal).map(a => !a) - ) --> toggle.writer - ), - name.map(n => - span( - cls := "ml-3", - idAttr := "active-only-label", - span(cls := "text-sm font-medium text-gray-900", n) - ) - ) - ) -} diff --git a/ui/src/ui/components/tailwind/TailwindSupport.scala b/ui/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index 2c7d850..0000000 --- a/ui/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,35 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "bg-red-100", - "bg-amber-100", - "bg-green-100" - ) diff --git a/ui/src/ui/components/tailwind/TimeUtils.scala b/ui/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 61ed625..0000000 --- a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - extension (v: V) def labeled(n: UIString): OptionalLabeledValue - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - extension (v: Option[V]) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] with - extension (d: LeftAlignedInCard[A]) - def element: HtmlElement = - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/src/ui/components/tailwind/form/ActionButtons.scala b/ui/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 49af7c0..0000000 --- a/ui/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.HtmlComponent - -case class ActionButton[A]( - title: UIString, - action: A -) - -// buttons to attach under for or detail cards -case class ActionButtons[A]( - actions: List[ActionButton[A]] -) - -object ActionButtons: - class Component[A](actions: Observer[A]) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - extension (v: ActionButtons[A]) - def element: Div = - div( - cls("flex justify-end"), - v.actions.zipWithIndex.map { - case (ActionButton(title, action), idx) => - button( - tpe("button"), - cls(if idx == 0 then "" else "ml-3"), - cls( - "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - title, - onClick.mapTo(action) --> actions - ) - } - ) diff --git a/ui/src/ui/components/tailwind/form/ComboBox.scala b/ui/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/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/ui/components/tailwind/form/Form.scala b/ui/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormBody.scala b/ui/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormFields.scala b/ui/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormHeader.scala b/ui/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormRow.scala b/ui/src/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormSection.scala b/ui/src/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/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/ui/components/tailwind/list/IconText.scala b/ui/src/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/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/ui/components/tailwind/list/ListRow.scala b/ui/src/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54a7769..0000000 --- a/ui/src/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +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 -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with - extension (r: ListRow) - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - 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", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = - val c = content - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) diff --git a/ui/src/ui/components/tailwind/list/PropList.scala b/ui/src/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/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/ui/components/tailwind/list/RowNext.scala b/ui/src/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/src/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`("h-5 w-5 text-gray-400") - ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 4d37f97..0000000 --- a/ui/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +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 - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - 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 deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/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/ui/components/tailwind/Component.scala b/ui/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 623862d..0000000 --- a/ui/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/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/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/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/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 56a0708..0000000 --- a/ui/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,315 +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.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - 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 deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/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/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/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/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/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/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index ec87e6b..0000000 --- a/ui/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate - -trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] - given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) diff --git a/ui/src/ui/components/tailwind/Switch.scala b/ui/src/ui/components/tailwind/Switch.scala deleted file mode 100644 index dd24ed0..0000000 --- a/ui/src/ui/components/tailwind/Switch.scala +++ /dev/null @@ -1,38 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString - -object Switch { - def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = - div( - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- toggle.signal.map(a => - if a then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- toggle.signal.map(a => - if a then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(toggle.signal).map(a => !a) - ) --> toggle.writer - ), - name.map(n => - span( - cls := "ml-3", - idAttr := "active-only-label", - span(cls := "text-sm font-medium text-gray-900", n) - ) - ) - ) -} diff --git a/ui/src/ui/components/tailwind/TailwindSupport.scala b/ui/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index 2c7d850..0000000 --- a/ui/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,35 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "bg-red-100", - "bg-amber-100", - "bg-green-100" - ) diff --git a/ui/src/ui/components/tailwind/TimeUtils.scala b/ui/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 61ed625..0000000 --- a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - extension (v: V) def labeled(n: UIString): OptionalLabeledValue - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - extension (v: Option[V]) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] with - extension (d: LeftAlignedInCard[A]) - def element: HtmlElement = - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/src/ui/components/tailwind/form/ActionButtons.scala b/ui/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 49af7c0..0000000 --- a/ui/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.HtmlComponent - -case class ActionButton[A]( - title: UIString, - action: A -) - -// buttons to attach under for or detail cards -case class ActionButtons[A]( - actions: List[ActionButton[A]] -) - -object ActionButtons: - class Component[A](actions: Observer[A]) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - extension (v: ActionButtons[A]) - def element: Div = - div( - cls("flex justify-end"), - v.actions.zipWithIndex.map { - case (ActionButton(title, action), idx) => - button( - tpe("button"), - cls(if idx == 0 then "" else "ml-3"), - cls( - "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - title, - onClick.mapTo(action) --> actions - ) - } - ) diff --git a/ui/src/ui/components/tailwind/form/ComboBox.scala b/ui/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/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/ui/components/tailwind/form/Form.scala b/ui/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormBody.scala b/ui/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormFields.scala b/ui/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormHeader.scala b/ui/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormRow.scala b/ui/src/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormSection.scala b/ui/src/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/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/ui/components/tailwind/list/IconText.scala b/ui/src/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/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/ui/components/tailwind/list/ListRow.scala b/ui/src/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54a7769..0000000 --- a/ui/src/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +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 -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with - extension (r: ListRow) - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - 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", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = - val c = content - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) diff --git a/ui/src/ui/components/tailwind/list/PropList.scala b/ui/src/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/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/ui/components/tailwind/list/RowNext.scala b/ui/src/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/src/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`("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 deleted file mode 100644 index 4ad12f4..0000000 --- a/ui/src/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): 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(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/components/src/services/files/File.scala b/ui/components/src/services/files/File.scala new file mode 100644 index 0000000..b936673 --- /dev/null +++ b/ui/components/src/services/files/File.scala @@ -0,0 +1,5 @@ +package works.iterative.services.files + +import java.time.Instant + +case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..e6bab0a --- /dev/null +++ b/ui/components/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.HtmlRenderable + +given HtmlRenderable[File] with + extension (m: File) + def render: 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/components/src/services/files/components/tailwind/FileList.scala b/ui/components/src/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..9108fee --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileList.scala @@ -0,0 +1,12 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.render) + ) diff --git a/ui/components/src/services/files/components/tailwind/FilePicker.scala b/ui/components/src/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..74029cb --- /dev/null +++ b/ui/components/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 + + def apply( + currentFiles: Signal[List[File]], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(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(_, availableFiles, headerActions)(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/components/src/services/files/components/tailwind/FileSelector.scala b/ui/components/src/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..cc4ee36 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,71 @@ +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], + availableFiles: Signal[List[File]], + headerActions: Option[HtmlElement] = None + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" + ), + 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 sm:justify-between"), + 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ů" + ) + ), + headerActions + ), + FileTable(availableFiles, Some(selectedFiles)) + ), + 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/components/src/services/files/components/tailwind/FileTable.scala b/ui/components/src/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..29567ca --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,141 @@ +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 java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.util.Locale +import works.iterative.ui.components.tailwind.TimeUtils + +def FileTable( + files: Signal[List[File]], + maybeSelection: Option[Var[Set[File]]] = None +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) + val openCategories = Var[Set[String]]( + maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) + ) + + 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( + maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), + th(baseM, textH, "Soubor"), + th(baseM, cls("w-40"), textH, "Vytvořen"), + th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + selected: File => Boolean, + toggleSelection: File => Observer[Unit] + )( + f: File + ): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + maybeSelection.map(_ => + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection(f), + cls(if selected(f) then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`("w-6 h-6 mx-auto"), + span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") + ) + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection(f) + ), + td( + baseC, + cls("font-medium text-gray-600 text-right"), + TimeUtils.formatDateTime(f.created), + onClick.mapTo(()) --> toggleSelection(f) + ), + 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" + ) + ) + ) + + def category( + renderRow: File => HtmlElement + )(name: String, files: List[File]): Signal[List[HtmlElement]] = + openCategories.signal.map(o => + tr( + cls("border-t border-gray-200"), + th( + cls("cursor-pointer"), + onClick.mapTo( + if o.contains(name) then o - name else o + name + ) --> openCategories, + colSpan(if (maybeSelection.isDefined) then 4 else 3), + scope("colgroup"), + cls( + "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" + ), + name + ) + ) :: (if o.contains(name) then files.map(renderRow) else Nil) + ) + + 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( + cls("bg-white"), + children <-- files + .combineWithFn(selectedFiles)((f, sel) => + val active = sel.contains + val renderCategory = category( + tableRow( + active, + file => + selectedFiles.writer.contramap(_ => + if active(file) then sel - file else sel + file + ) + ) + ) + Signal + .combineSeq( + f.groupBy(_.category) + .to(List) + .map(renderCategory(_, _)) + ) + .map(_.flatten) + ) + .flatten + ) + ) + ) + ) + ) diff --git a/ui/components/src/services/files/components/tailwind/UploadButton.scala b/ui/components/src/services/files/components/tailwind/UploadButton.scala new file mode 100644 index 0000000..dbb0c24 --- /dev/null +++ b/ui/components/src/services/files/components/tailwind/UploadButton.scala @@ -0,0 +1,46 @@ +package services.files.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.UIString +import org.scalajs.dom.FileList +import works.iterative.ui.components.tailwind.HtmlComponent + +case class UploadButton(title: UIString) + +object UploadButton: + class Component(upload: Observer[FileList]) + extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: + extension (u: UploadButton) + override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = + div( + cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", + label( + cls("block w-full"), + div( + cls( + "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" + ), { + import svg.* + svg( + cls("w-6 h-6"), + fill := "#FFF", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path(d := "M0 0h24v24H0z", fill := "none"), + path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") + ) + }, + span(cls := "ml-2", u.title) + ), + input( + cls := "cursor-pointer hidden", + tpe := "file", + name := "files", + multiple := true, + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> upload + ) + ) + ) + ) diff --git a/ui/components/src/ui/UIString.scala b/ui/components/src/ui/UIString.scala new file mode 100644 index 0000000..64c8f0f --- /dev/null +++ b/ui/components/src/ui/UIString.scala @@ -0,0 +1,22 @@ +package works.iterative.ui + +import com.raquo.laminar.nodes.TextNode + +/** UIString is meant to mark the strings that are part of the UI and subject to + * localization Another mechanism can later be used to find all these strings + * and customize. + */ +opaque type UIString = String + +object UIString: + def apply(s: String): UIString = s + + extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) + + extension (s: String) inline def ui: UIString = UIString(s) + + given Conversion[UIString, TextNode] with + def apply(ui: UIString): TextNode = ui.toNode + + given Conversion[UIString, String] with + inline def apply(ui: UIString): String = ui diff --git a/ui/components/src/ui/components/headless/Items.scala b/ui/components/src/ui/components/headless/Items.scala new file mode 100644 index 0000000..aea5ec0 --- /dev/null +++ b/ui/components/src/ui/components/headless/Items.scala @@ -0,0 +1,33 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait ItemContainer[A]: + def contramap[B](f: B => A): ItemContainer[B] + def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] + +final case class Items[A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItem: A => HtmlElement +) extends ItemContainer[A]: + def apply(items: Seq[A]): HtmlElement = frame( + items.map(a => li(renderItem(a))) + ) + def contramap[B](f: B => A): Items[B] = + Items(frame, b => renderItem(f(b))) + def map(f: A => HtmlElement => HtmlElement): Items[A] = + Items(frame, a => f(a)(renderItem(a))) + +final case class GroupedItems[Key, A]( + frame: Seq[HtmlElement] => HtmlElement, + renderItems: Key => Items[A] +) extends ItemContainer[A]: + def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( + items.map(renderItems(_)(_)) + ) + def contramap[B](f: B => A): GroupedItems[Key, B] = + GroupedItems(frame, k => renderItems(k).contramap(f)) + def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = + GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/components/src/ui/components/headless/Toggle.scala b/ui/components/src/ui/components/headless/Toggle.scala new file mode 100644 index 0000000..8e6cab2 --- /dev/null +++ b/ui/components/src/ui/components/headless/Toggle.scala @@ -0,0 +1,31 @@ +package works.iterative +package ui.components.headless + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Toggle: + + final case class Ctx( + trigger: Modifier[HtmlElement], + toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] + ) + + def apply[U <: org.scalajs.dom.html.Element]( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = apply(true)(children) + + def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( + children: Ctx => ReactiveHtmlElement[U] + ): ReactiveHtmlElement[U] = + val state: Var[Boolean] = Var(initialValue) + children( + Ctx( + composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, + el => + state.signal.map { + case true => el + case _ => Nil + } + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Avatar.scala b/ui/components/src/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..4d37f97 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Avatar.scala @@ -0,0 +1,40 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: macros for size +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder( + extraClasses: String, + iconClasses: String + ): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", + cls := extraClasses, + Icons.outline.user(iconClasses) + ) + + inline def avatarImage( + extraClasses: String, + iconClasses: String + ): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"rounded-full", + cls := extraClasses, + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) + + inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(extraClasses, iconClasses), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/components/src/ui/components/tailwind/Color.scala b/ui/components/src/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Component.scala b/ui/components/src/ui/components/tailwind/Component.scala new file mode 100644 index 0000000..623862d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Component.scala @@ -0,0 +1,9 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +trait HtmlComponent[Ref <: dom.html.Element, A]: + extension (a: A) def element: ReactiveHtmlElement[Ref] + +type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/components/src/ui/components/tailwind/CustomAttrs.scala b/ui/components/src/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Display.scala b/ui/components/src/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Icons.scala b/ui/components/src/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..56a0708 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Icons.scala @@ -0,0 +1,315 @@ +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 +import com.raquo.domtypes.generic.codecs.StringAsIsCodec + +object Icons: + object aria: + val hidden = CustomAttrs.svg.ariaHidden + + inline def spinner(extraClasses: String): SvgElement = + svg( + cls(extraClasses), + customSvgAttr("role", StringAsIsCodec) := "status", + cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", + viewBox := "0 0 100 101", + fill := "none", + xmlns := "http://www.w3.org/2000/svg", + path( + d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", + fill := "currentColor" + ), + path( + d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", + fill := "currentFill" + ) + ) + + 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/components/src/ui/components/tailwind/LinkSupport.scala b/ui/components/src/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Loader.scala b/ui/components/src/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Macros.scala b/ui/components/src/ui/components/tailwind/Macros.scala new file mode 100644 index 0000000..f36d7ed --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..ec87e6b --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -0,0 +1,18 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import java.time.LocalDate + +trait HtmlRenderable[A]: + extension (a: A) def render: Modifier[HtmlElement] + +object HtmlRenderable: + given stringValue: HtmlRenderable[String] with + extension (v: String) + def render: Modifier[HtmlElement] = + v: Modifier[HtmlElement] + given dateValue: HtmlRenderable[LocalDate] with + extension (v: LocalDate) + def render: Modifier[HtmlElement] = + TimeUtils.formatDate(v) diff --git a/ui/components/src/ui/components/tailwind/Switch.scala b/ui/components/src/ui/components/tailwind/Switch.scala new file mode 100644 index 0000000..dd24ed0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/Switch.scala @@ -0,0 +1,38 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString + +object Switch { + def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = + div( + cls := "flex items-center", + button( + tpe := "button", + cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + cls <-- toggle.signal.map(a => + if a then "bg-indigo-600" else "bg-gray-200" + ), + role := "switch", + dataAttr("aria-checked") := "false", + dataAttr("aria-labelledby") := "active-only-label", + span( + dataAttr("aria-hidden") := "true", + cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", + cls <-- toggle.signal.map(a => + if a then "translate-x-5" else "translate-x-0" + ) + ), + composeEvents(onClick)( + _.sample(toggle.signal).map(a => !a) + ) --> toggle.writer + ), + name.map(n => + span( + cls := "ml-3", + idAttr := "active-only-label", + span(cls := "text-sm font-medium text-gray-900", n) + ) + ) + ) +} diff --git a/ui/components/src/ui/components/tailwind/TailwindSupport.scala b/ui/components/src/ui/components/tailwind/TailwindSupport.scala new file mode 100644 index 0000000..2c7d850 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TailwindSupport.scala @@ -0,0 +1,35 @@ +package ui.components.tailwind + +trait TailwindSupport: + // Make sure certain classes are included in the Tailwind JIT compiler + // The sizes and colors that we generate dynamically are missed + // So until I figure out how to use macros to mitigate, this should do + val extraUserClasses: List[String] = + List( + "h-2", + "h-3", + "h-4", + "h-5", + "h-6", + "h-8", + "h-10", + "h-12", + "h-14", + "h-16", + "w-2", + "w-3", + "w-4", + "w-5", + "w-6", + "w-8", + "w-10", + "w-12", + "w-14", + "w-16", + "text-red-800", + "text-amber-800", + "text-green-800", + "bg-red-100", + "bg-amber-100", + "bg-green-100" + ) diff --git a/ui/components/src/ui/components/tailwind/TimeUtils.scala b/ui/components/src/ui/components/tailwind/TimeUtils.scala new file mode 100644 index 0000000..3bb2d6e --- /dev/null +++ b/ui/components/src/ui/components/tailwind/TimeUtils.scala @@ -0,0 +1,30 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.ZoneId +import java.time.Instant +import java.time.temporal.TemporalAccessor + +object TimeUtils: + val dateTimeFormat = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + val dateFormat = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + // TODO: locale + // .withLocale(Locale("cs", "CZ")) + .withZone(ZoneId.of("CET")) + + def formatDateTime(i: TemporalAccessor): String = + dateTimeFormat.format(i) + + def formatDate(i: TemporalAccessor): String = + dateFormat.format(i) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala new file mode 100644 index 0000000..61ed625 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -0,0 +1,79 @@ +package works.iterative.ui.components.tailwind.data_display.description_lists + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.TimeUtils +import java.time.LocalDate +import works.iterative.ui.components.tailwind.BaseHtmlComponent +import works.iterative.ui.components.tailwind.HtmlRenderable +import works.iterative.ui.components.tailwind.form.ActionButtons +import works.iterative.ui.components.tailwind.HtmlComponent +import works.iterative.ui.components.tailwind.form.ActionButton + +case class LeftAlignedInCard[A]( + title: String, + subtitle: String, + data: List[LeftAlignedInCard.OptionalLabeledValue], + // TODO: a version without actions + actions: List[ActionButton[A]] +) + +object LeftAlignedInCard: + case class OptionalLabeledValue( + label: UIString, + v: Option[Modifier[HtmlElement]] + ) + + trait AsValue[V]: + extension (v: V) def labeled(n: UIString): OptionalLabeledValue + + given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with + extension (v: Option[V]) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) + + given [V: HtmlRenderable]: AsValue[V] with + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) + + given leftAlignedInCardComponent[A](using + HtmlComponent[_, ActionButtons[A]] + ): BaseHtmlComponent[LeftAlignedInCard[A]] with + extension (d: LeftAlignedInCard[A]) + def element: HtmlElement = + div( + cls := "bg-white shadow overflow-hidden sm:rounded-lg", + div( + cls := "px-4 py-5 sm:px-6", + h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) + ), + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + dl( + cls := "sm:divide-y sm:divide-gray-200", + d.data.collect { case OptionalLabeledValue(label, Some(body)) => + div( + cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", + dt(cls := "text-sm font-medium text-gray-500", label), + dd( + cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", + body + ) + ) + } + ) + ), + if d.actions.nonEmpty then + Some( + div( + cls := "border-t border-gray-200 px-4 py-5 sm:p-0", + div( + cls := "px-4 py-5 sm:px-6", + ActionButtons(d.actions).element + ) + ) + ) + else None + ) diff --git a/ui/components/src/ui/components/tailwind/form/ActionButtons.scala b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala new file mode 100644 index 0000000..49af7c0 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/ActionButtons.scala @@ -0,0 +1,37 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.UIString +import works.iterative.ui.components.tailwind.HtmlComponent + +case class ActionButton[A]( + title: UIString, + action: A +) + +// buttons to attach under for or detail cards +case class ActionButtons[A]( + actions: List[ActionButton[A]] +) + +object ActionButtons: + class Component[A](actions: Observer[A]) + extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: + extension (v: ActionButtons[A]) + def element: Div = + div( + cls("flex justify-end"), + v.actions.zipWithIndex.map { + case (ActionButton(title, action), idx) => + button( + tpe("button"), + cls(if idx == 0 then "" else "ml-3"), + cls( + "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + ), + title, + onClick.mapTo(action) --> actions + ) + } + ) diff --git a/ui/components/src/ui/components/tailwind/form/ComboBox.scala b/ui/components/src/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/Form.scala b/ui/components/src/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormBody.scala b/ui/components/src/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormFields.scala b/ui/components/src/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormHeader.scala b/ui/components/src/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormRow.scala b/ui/components/src/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/form/FormSection.scala b/ui/components/src/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/IconText.scala b/ui/components/src/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/ListRow.scala b/ui/components/src/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..54a7769 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,59 @@ +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 +import com.raquo.laminar.nodes.ReactiveHtmlElement + +trait AsListRow[A]: + extension (a: A) def asListRow: ListRow + +final case class ListRow( + title: HtmlElement, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + linkMods: Option[Modifier[Anchor]] = None +) + +object ListRow: + + given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with + extension (r: ListRow) + def content: Modifier[HtmlElement] = + Seq( + cls := "block hover:bg-gray-50", + 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", + r.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + r.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + r.bottomLeft, + r.bottomRight + ) + ), + r.farRight + ) + ) + + def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = + val c = content + li( + r.linkMods match + case Some(m) => a(m, c) + case _ => div(c) + ) diff --git a/ui/components/src/ui/components/tailwind/list/PropList.scala b/ui/components/src/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowNext.scala b/ui/components/src/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..7e411c9 --- /dev/null +++ b/ui/components/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/components/src/ui/components/tailwind/list/RowTag.scala b/ui/components/src/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..4ad12f4 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + def apply(text: String, color: Color): 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(colorClass(color)), + cls := (color match { + case Color.red => "text-red-800 bg-red-100" + case Color.amber => "text-amber-800 bg-amber-100" + case Color.green => "text-green-800 bg-green-100" + case _ => "text-gray-800 bg-gray-100" + }), + text + ) diff --git a/ui/components/src/ui/components/tailwind/list/StackedList.scala b/ui/components/src/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..c8599f9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,145 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement +import works.iterative.ui.components.headless.Items +import works.iterative.ui.components.headless.GroupedItems +import works.iterative.ui.components.headless.Toggle + +class StackedList[Item: AsListRow]: + import StackedList.* + def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = + ul( + role := "list", + cls := "divide-y divide-gray-200", + items.map(d => d.asListRow.element) + ) + + def withMod( + items: List[Item] + ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => + ul( + role := "list", + cls := "divide-y divide-gray-200", + mods, + items.map(d => d.asListRow.element) + ) + + def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = + items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => + withHeader(c)(withMod(i)) + } + +object StackedList: + def withHeader(header: String)( + content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] + ): HtmlElement = + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + header + ), + content(cls("relative")) + ) + +object StackedListWithRightJustifiedSecondColumn: + opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] + opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] + opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] + opaque type RightProp = Div + + case class Item( + title: Title, + tag: Tag, + leftProps: Seq[LeftProp] = Nil, + rightProp: Option[RightProp] = None + ) + + def title(text: String): Title = + p( + cls := "text-sm font-medium text-indigo-600 truncate", + text + ) + + def tag(text: String, color: Color): Tag = + p( + cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), + text + ) + + def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = + p( + cls( + "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" + ), + icon, + text + ) + + def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = + div( + cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), + icon, + text + ) + + private def item(i: Item): Div = item(i, None) + + private def item(i: Item, extraClasses: Option[String]): Div = + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + extraClasses.map(cls(_)), + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + i.title, + div(cls := "ml-2 flex-shrink-0 flex", i.tag) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + div( + cls("sm:flex"), + i.leftProps + ), + i.rightProp + ) + ) + ) + + private def headerFrame(text: String): Seq[HtmlElement] => Div = + content => + Toggle(ctx => + div( + cls("relative"), + h3( + cls( + "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" + ), + text, + ctx.trigger + ), + children <-- ctx.toggle(content) + ) + ) + + private def frame: Seq[HtmlElement] => Div = + el => + div( + cls("bg-white shadow overflow-hidden sm:rounded-md"), + ul(role("list"), cls("divide-y divide-gray-200"), el) + ) + + def apply[A](f: this.type => A => Item): Items[A] = + Items(frame, item).contramap(f(this)) + + def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = + GroupedItems( + frame, + k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) + ) diff --git a/ui/src/services/files/File.scala b/ui/src/services/files/File.scala deleted file mode 100644 index b936673..0000000 --- a/ui/src/services/files/File.scala +++ /dev/null @@ -1,5 +0,0 @@ -package works.iterative.services.files - -import java.time.Instant - -case class File(url: String, name: String, category: String, created: Instant) diff --git a/ui/src/services/files/components/tailwind/File.scala b/ui/src/services/files/components/tailwind/File.scala deleted file mode 100644 index e6bab0a..0000000 --- a/ui/src/services/files/components/tailwind/File.scala +++ /dev/null @@ -1,23 +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.HtmlRenderable - -given HtmlRenderable[File] with - extension (m: File) - def render: 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 deleted file mode 100644 index 9108fee..0000000 --- a/ui/src/services/files/components/tailwind/FileList.scala +++ /dev/null @@ -1,12 +0,0 @@ -package works.iterative.services.files -package components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.components.tailwind.Icons - -def FileList(files: List[File]): HtmlElement = - ul( - role("list"), - cls("border border-gray-200 rounded-md divide-y divide-gray-200"), - files.map(_.render) - ) diff --git a/ui/src/services/files/components/tailwind/FilePicker.scala b/ui/src/services/files/components/tailwind/FilePicker.scala deleted file mode 100644 index 74029cb..0000000 --- a/ui/src/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 - - def apply( - currentFiles: Signal[List[File]], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(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(_, availableFiles, headerActions)(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 deleted file mode 100644 index cc4ee36..0000000 --- a/ui/src/services/files/components/tailwind/FileSelector.scala +++ /dev/null @@ -1,71 +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], - availableFiles: Signal[List[File]], - headerActions: Option[HtmlElement] = None - )(selectionUpdates: Observer[Event]): HtmlElement = - val selectedFiles = Var[Set[File]](initialFiles.to(Set)) - div( - cls( - "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl sm:align-middle" - ), - 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 sm:justify-between"), - 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ů" - ) - ), - headerActions - ), - FileTable(availableFiles, Some(selectedFiles)) - ), - 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 deleted file mode 100644 index 29567ca..0000000 --- a/ui/src/services/files/components/tailwind/FileTable.scala +++ /dev/null @@ -1,141 +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 java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.util.Locale -import works.iterative.ui.components.tailwind.TimeUtils - -def FileTable( - files: Signal[List[File]], - maybeSelection: Option[Var[Set[File]]] = None -): HtmlElement = - val scope = customHtmlAttr("scope", StringAsIsCodec) - val selectedFiles = maybeSelection.getOrElse(Var(Set.empty)) - val openCategories = Var[Set[String]]( - maybeSelection.map(_.now().map(_.category)).getOrElse(Set.empty) - ) - - 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( - maybeSelection.map(_ => th(baseM, span(cls("sr-only"), "Vybrat"))), - th(baseM, textH, "Soubor"), - th(baseM, cls("w-40"), textH, "Vytvořen"), - th(baseM, cls("w-32 relative"), span(cls("sr-only"), "Otevřít")) - ) - - def tableRow( - selected: File => Boolean, - toggleSelection: File => Observer[Unit] - )( - f: File - ): HtmlElement = - val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") - tr( - maybeSelection.map(_ => - td( - cls("font-medium cursor-pointer"), - onClick.mapTo(()) --> toggleSelection(f), - cls(if selected(f) then "text-green-900" else "text-gray-200"), - Icons.outline.`check-circle`("w-6 h-6 mx-auto"), - span(cls("sr-only"), if selected(f) then "Vybráno" else "Nevybráno") - ) - ), - td( - baseC, - cls("font-medium text-gray-900"), - f.name, - onClick.mapTo(()) --> toggleSelection(f) - ), - td( - baseC, - cls("font-medium text-gray-600 text-right"), - TimeUtils.formatDateTime(f.created), - onClick.mapTo(()) --> toggleSelection(f) - ), - 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" - ) - ) - ) - - def category( - renderRow: File => HtmlElement - )(name: String, files: List[File]): Signal[List[HtmlElement]] = - openCategories.signal.map(o => - tr( - cls("border-t border-gray-200"), - th( - cls("cursor-pointer"), - onClick.mapTo( - if o.contains(name) then o - name else o + name - ) --> openCategories, - colSpan(if (maybeSelection.isDefined) then 4 else 3), - scope("colgroup"), - cls( - "bg-gray-50 px-4 py-2 text-left text-sm font-semibold text-gray-900 sm:px-6" - ), - name - ) - ) :: (if o.contains(name) then files.map(renderRow) else Nil) - ) - - 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( - cls("bg-white"), - children <-- files - .combineWithFn(selectedFiles)((f, sel) => - val active = sel.contains - val renderCategory = category( - tableRow( - active, - file => - selectedFiles.writer.contramap(_ => - if active(file) then sel - file else sel + file - ) - ) - ) - Signal - .combineSeq( - f.groupBy(_.category) - .to(List) - .map(renderCategory(_, _)) - ) - .map(_.flatten) - ) - .flatten - ) - ) - ) - ) - ) diff --git a/ui/src/services/files/components/tailwind/UploadButton.scala b/ui/src/services/files/components/tailwind/UploadButton.scala deleted file mode 100644 index dbb0c24..0000000 --- a/ui/src/services/files/components/tailwind/UploadButton.scala +++ /dev/null @@ -1,46 +0,0 @@ -package services.files.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.UIString -import org.scalajs.dom.FileList -import works.iterative.ui.components.tailwind.HtmlComponent - -case class UploadButton(title: UIString) - -object UploadButton: - class Component(upload: Observer[FileList]) - extends HtmlComponent[org.scalajs.dom.html.Div, UploadButton]: - extension (u: UploadButton) - override def element: ReactiveHtmlElement[org.scalajs.dom.html.Div] = - div( - cls := "mt-4 sm:mt-0 sm:ml-16 sm:flex-none", - label( - cls("block w-full"), - div( - cls( - "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" - ), { - import svg.* - svg( - cls("w-6 h-6"), - fill := "#FFF", - viewBox := "0 0 24 24", - xmlns := "http://www.w3.org/2000/svg", - path(d := "M0 0h24v24H0z", fill := "none"), - path(d := "M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z") - ) - }, - span(cls := "ml-2", u.title) - ), - input( - cls := "cursor-pointer hidden", - tpe := "file", - name := "files", - multiple := true, - inContext(thisNode => - onInput.mapTo(thisNode.ref.files) --> upload - ) - ) - ) - ) diff --git a/ui/src/ui/UIString.scala b/ui/src/ui/UIString.scala deleted file mode 100644 index 64c8f0f..0000000 --- a/ui/src/ui/UIString.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui - -import com.raquo.laminar.nodes.TextNode - -/** UIString is meant to mark the strings that are part of the UI and subject to - * localization Another mechanism can later be used to find all these strings - * and customize. - */ -opaque type UIString = String - -object UIString: - def apply(s: String): UIString = s - - extension (ui: UIString) inline def toNode: TextNode = TextNode(ui) - - extension (s: String) inline def ui: UIString = UIString(s) - - given Conversion[UIString, TextNode] with - def apply(ui: UIString): TextNode = ui.toNode - - given Conversion[UIString, String] with - inline def apply(ui: UIString): String = ui diff --git a/ui/src/ui/components/headless/Items.scala b/ui/src/ui/components/headless/Items.scala deleted file mode 100644 index aea5ec0..0000000 --- a/ui/src/ui/components/headless/Items.scala +++ /dev/null @@ -1,33 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait ItemContainer[A]: - def contramap[B](f: B => A): ItemContainer[B] - def map(f: A => HtmlElement => HtmlElement): ItemContainer[A] - -final case class Items[A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItem: A => HtmlElement -) extends ItemContainer[A]: - def apply(items: Seq[A]): HtmlElement = frame( - items.map(a => li(renderItem(a))) - ) - def contramap[B](f: B => A): Items[B] = - Items(frame, b => renderItem(f(b))) - def map(f: A => HtmlElement => HtmlElement): Items[A] = - Items(frame, a => f(a)(renderItem(a))) - -final case class GroupedItems[Key, A]( - frame: Seq[HtmlElement] => HtmlElement, - renderItems: Key => Items[A] -) extends ItemContainer[A]: - def apply(items: Seq[(Key, Seq[A])]): HtmlElement = frame( - items.map(renderItems(_)(_)) - ) - def contramap[B](f: B => A): GroupedItems[Key, B] = - GroupedItems(frame, k => renderItems(k).contramap(f)) - def map(f: A => HtmlElement => HtmlElement): GroupedItems[Key, A] = - GroupedItems(frame, k => renderItems(k).map(f)) diff --git a/ui/src/ui/components/headless/Toggle.scala b/ui/src/ui/components/headless/Toggle.scala deleted file mode 100644 index 8e6cab2..0000000 --- a/ui/src/ui/components/headless/Toggle.scala +++ /dev/null @@ -1,31 +0,0 @@ -package works.iterative -package ui.components.headless - -import com.raquo.laminar.api.L.{*, given} -import com.raquo.laminar.nodes.ReactiveHtmlElement - -object Toggle: - - final case class Ctx( - trigger: Modifier[HtmlElement], - toggle: Seq[HtmlElement] => Signal[Seq[HtmlElement]] - ) - - def apply[U <: org.scalajs.dom.html.Element]( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = apply(true)(children) - - def apply[U <: org.scalajs.dom.html.Element](initialValue: Boolean)( - children: Ctx => ReactiveHtmlElement[U] - ): ReactiveHtmlElement[U] = - val state: Var[Boolean] = Var(initialValue) - children( - Ctx( - composeEvents(onClick)(_.sample(state).map(v => !v)) --> state, - el => - state.signal.map { - case true => el - case _ => Nil - } - ) - ) diff --git a/ui/src/ui/components/tailwind/Avatar.scala b/ui/src/ui/components/tailwind/Avatar.scala deleted file mode 100644 index 4d37f97..0000000 --- a/ui/src/ui/components/tailwind/Avatar.scala +++ /dev/null @@ -1,40 +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 - -// TODO: macros for size -class Avatar($avatarImg: Signal[Option[String]]): - inline def avatarPlaceholder( - extraClasses: String, - iconClasses: String - ): HtmlElement = - div( - cls := s"rounded-full text-indigo-200 bg-indigo-500 flex items-center justify-center", - cls := extraClasses, - Icons.outline.user(iconClasses) - ) - - inline def avatarImage( - extraClasses: String, - iconClasses: String - ): Signal[HtmlElement] = - $avatarImg.split(_ => ())((_, _, $url) => - img( - cls := s"rounded-full", - cls := extraClasses, - src <-- $url, - alt := "" - ) - ).map(_.getOrElse(avatarPlaceholder(extraClasses, iconClasses))) - - inline def avatar(extraClasses: String, iconClasses: String): HtmlElement = - div( - cls := "relative", - child <-- avatarImage(extraClasses, iconClasses), - 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 deleted file mode 100644 index bcbed88..0000000 --- a/ui/src/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/ui/components/tailwind/Component.scala b/ui/src/ui/components/tailwind/Component.scala deleted file mode 100644 index 623862d..0000000 --- a/ui/src/ui/components/tailwind/Component.scala +++ /dev/null @@ -1,9 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.nodes.ReactiveHtmlElement -import org.scalajs.dom - -trait HtmlComponent[Ref <: dom.html.Element, A]: - extension (a: A) def element: ReactiveHtmlElement[Ref] - -type BaseHtmlComponent[A] = HtmlComponent[dom.html.Element, A] diff --git a/ui/src/ui/components/tailwind/CustomAttrs.scala b/ui/src/ui/components/tailwind/CustomAttrs.scala deleted file mode 100644 index 6fb453c..0000000 --- a/ui/src/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/ui/components/tailwind/Display.scala b/ui/src/ui/components/tailwind/Display.scala deleted file mode 100644 index 6cda48b..0000000 --- a/ui/src/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/ui/components/tailwind/Icons.scala b/ui/src/ui/components/tailwind/Icons.scala deleted file mode 100644 index 56a0708..0000000 --- a/ui/src/ui/components/tailwind/Icons.scala +++ /dev/null @@ -1,315 +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.api.L.SvgElement -import com.raquo.laminar.builders.SvgBuilders -import com.raquo.laminar.keys.ReactiveSvgAttr -import com.raquo.domtypes.generic.codecs.StringAsIsCodec - -object Icons: - object aria: - val hidden = CustomAttrs.svg.ariaHidden - - inline def spinner(extraClasses: String): SvgElement = - svg( - cls(extraClasses), - customSvgAttr("role", StringAsIsCodec) := "status", - cls := "inline mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-indigo-600", - viewBox := "0 0 100 101", - fill := "none", - xmlns := "http://www.w3.org/2000/svg", - path( - d := "M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z", - fill := "currentColor" - ), - path( - d := "M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z", - fill := "currentFill" - ) - ) - - 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 deleted file mode 100644 index 88f0b9a..0000000 --- a/ui/src/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/ui/components/tailwind/Loader.scala b/ui/src/ui/components/tailwind/Loader.scala deleted file mode 100644 index 5399933..0000000 --- a/ui/src/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/ui/components/tailwind/Macros.scala b/ui/src/ui/components/tailwind/Macros.scala deleted file mode 100644 index f36d7ed..0000000 --- a/ui/src/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/ui/components/tailwind/Renderable.scala b/ui/src/ui/components/tailwind/Renderable.scala deleted file mode 100644 index ec87e6b..0000000 --- a/ui/src/ui/components/tailwind/Renderable.scala +++ /dev/null @@ -1,18 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom -import java.time.LocalDate - -trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] - -object HtmlRenderable: - given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] - given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) diff --git a/ui/src/ui/components/tailwind/Switch.scala b/ui/src/ui/components/tailwind/Switch.scala deleted file mode 100644 index dd24ed0..0000000 --- a/ui/src/ui/components/tailwind/Switch.scala +++ /dev/null @@ -1,38 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString - -object Switch { - def apply(toggle: Var[Boolean], name: Option[UIString] = None): HtmlElement = - div( - cls := "flex items-center", - button( - tpe := "button", - cls := "relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", - cls <-- toggle.signal.map(a => - if a then "bg-indigo-600" else "bg-gray-200" - ), - role := "switch", - dataAttr("aria-checked") := "false", - dataAttr("aria-labelledby") := "active-only-label", - span( - dataAttr("aria-hidden") := "true", - cls := "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200", - cls <-- toggle.signal.map(a => - if a then "translate-x-5" else "translate-x-0" - ) - ), - composeEvents(onClick)( - _.sample(toggle.signal).map(a => !a) - ) --> toggle.writer - ), - name.map(n => - span( - cls := "ml-3", - idAttr := "active-only-label", - span(cls := "text-sm font-medium text-gray-900", n) - ) - ) - ) -} diff --git a/ui/src/ui/components/tailwind/TailwindSupport.scala b/ui/src/ui/components/tailwind/TailwindSupport.scala deleted file mode 100644 index 2c7d850..0000000 --- a/ui/src/ui/components/tailwind/TailwindSupport.scala +++ /dev/null @@ -1,35 +0,0 @@ -package ui.components.tailwind - -trait TailwindSupport: - // Make sure certain classes are included in the Tailwind JIT compiler - // The sizes and colors that we generate dynamically are missed - // So until I figure out how to use macros to mitigate, this should do - val extraUserClasses: List[String] = - List( - "h-2", - "h-3", - "h-4", - "h-5", - "h-6", - "h-8", - "h-10", - "h-12", - "h-14", - "h-16", - "w-2", - "w-3", - "w-4", - "w-5", - "w-6", - "w-8", - "w-10", - "w-12", - "w-14", - "w-16", - "text-red-800", - "text-amber-800", - "text-green-800", - "bg-red-100", - "bg-amber-100", - "bg-green-100" - ) diff --git a/ui/src/ui/components/tailwind/TimeUtils.scala b/ui/src/ui/components/tailwind/TimeUtils.scala deleted file mode 100644 index 3bb2d6e..0000000 --- a/ui/src/ui/components/tailwind/TimeUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package works.iterative.ui.components.tailwind - -import com.raquo.laminar.api.L.{*, given} - -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.time.ZoneId -import java.time.Instant -import java.time.temporal.TemporalAccessor - -object TimeUtils: - val dateTimeFormat = - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - val dateFormat = - DateTimeFormatter - .ofLocalizedDate(FormatStyle.SHORT) - // TODO: locale - // .withLocale(Locale("cs", "CZ")) - .withZone(ZoneId.of("CET")) - - def formatDateTime(i: TemporalAccessor): String = - dateTimeFormat.format(i) - - def formatDate(i: TemporalAccessor): String = - dateFormat.format(i) diff --git a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala deleted file mode 100644 index 61ed625..0000000 --- a/ui/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ /dev/null @@ -1,79 +0,0 @@ -package works.iterative.ui.components.tailwind.data_display.description_lists - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.TimeUtils -import java.time.LocalDate -import works.iterative.ui.components.tailwind.BaseHtmlComponent -import works.iterative.ui.components.tailwind.HtmlRenderable -import works.iterative.ui.components.tailwind.form.ActionButtons -import works.iterative.ui.components.tailwind.HtmlComponent -import works.iterative.ui.components.tailwind.form.ActionButton - -case class LeftAlignedInCard[A]( - title: String, - subtitle: String, - data: List[LeftAlignedInCard.OptionalLabeledValue], - // TODO: a version without actions - actions: List[ActionButton[A]] -) - -object LeftAlignedInCard: - case class OptionalLabeledValue( - label: UIString, - v: Option[Modifier[HtmlElement]] - ) - - trait AsValue[V]: - extension (v: V) def labeled(n: UIString): OptionalLabeledValue - - given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - extension (v: Option[V]) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) - - given [V: HtmlRenderable]: AsValue[V] with - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) - - given leftAlignedInCardComponent[A](using - HtmlComponent[_, ActionButtons[A]] - ): BaseHtmlComponent[LeftAlignedInCard[A]] with - extension (d: LeftAlignedInCard[A]) - def element: HtmlElement = - div( - cls := "bg-white shadow overflow-hidden sm:rounded-lg", - div( - cls := "px-4 py-5 sm:px-6", - h3(cls := "text-lg leading-6 font-medium text-gray-900", d.title), - p(cls := "mt-1 max-w-2xl text-sm text-gray-500", d.subtitle) - ), - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - dl( - cls := "sm:divide-y sm:divide-gray-200", - d.data.collect { case OptionalLabeledValue(label, Some(body)) => - div( - cls := "py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6", - dt(cls := "text-sm font-medium text-gray-500", label), - dd( - cls := "mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2", - body - ) - ) - } - ) - ), - if d.actions.nonEmpty then - Some( - div( - cls := "border-t border-gray-200 px-4 py-5 sm:p-0", - div( - cls := "px-4 py-5 sm:px-6", - ActionButtons(d.actions).element - ) - ) - ) - else None - ) diff --git a/ui/src/ui/components/tailwind/form/ActionButtons.scala b/ui/src/ui/components/tailwind/form/ActionButtons.scala deleted file mode 100644 index 49af7c0..0000000 --- a/ui/src/ui/components/tailwind/form/ActionButtons.scala +++ /dev/null @@ -1,37 +0,0 @@ -package works.iterative -package ui.components.tailwind.form - -import com.raquo.laminar.api.L.{*, given} -import works.iterative.ui.UIString -import works.iterative.ui.components.tailwind.HtmlComponent - -case class ActionButton[A]( - title: UIString, - action: A -) - -// buttons to attach under for or detail cards -case class ActionButtons[A]( - actions: List[ActionButton[A]] -) - -object ActionButtons: - class Component[A](actions: Observer[A]) - extends HtmlComponent[org.scalajs.dom.html.Div, ActionButtons[A]]: - extension (v: ActionButtons[A]) - def element: Div = - div( - cls("flex justify-end"), - v.actions.zipWithIndex.map { - case (ActionButton(title, action), idx) => - button( - tpe("button"), - cls(if idx == 0 then "" else "ml-3"), - cls( - "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" - ), - title, - onClick.mapTo(action) --> actions - ) - } - ) diff --git a/ui/src/ui/components/tailwind/form/ComboBox.scala b/ui/src/ui/components/tailwind/form/ComboBox.scala deleted file mode 100644 index 8c5b8a1..0000000 --- a/ui/src/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/ui/components/tailwind/form/Form.scala b/ui/src/ui/components/tailwind/form/Form.scala deleted file mode 100644 index 1b41dee..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormBody.scala b/ui/src/ui/components/tailwind/form/FormBody.scala deleted file mode 100644 index 89abc9c..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormFields.scala b/ui/src/ui/components/tailwind/form/FormFields.scala deleted file mode 100644 index a2e4cc6..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormHeader.scala b/ui/src/ui/components/tailwind/form/FormHeader.scala deleted file mode 100644 index 4083841..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormRow.scala b/ui/src/ui/components/tailwind/form/FormRow.scala deleted file mode 100644 index ffc5807..0000000 --- a/ui/src/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/ui/components/tailwind/form/FormSection.scala b/ui/src/ui/components/tailwind/form/FormSection.scala deleted file mode 100644 index 50811ee..0000000 --- a/ui/src/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/ui/components/tailwind/list/IconText.scala b/ui/src/ui/components/tailwind/list/IconText.scala deleted file mode 100644 index 9f5a7b1..0000000 --- a/ui/src/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/ui/components/tailwind/list/ListRow.scala b/ui/src/ui/components/tailwind/list/ListRow.scala deleted file mode 100644 index 54a7769..0000000 --- a/ui/src/ui/components/tailwind/list/ListRow.scala +++ /dev/null @@ -1,59 +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 -import com.raquo.laminar.nodes.ReactiveHtmlElement - -trait AsListRow[A]: - extension (a: A) def asListRow: ListRow - -final case class ListRow( - title: HtmlElement, - topRight: Modifier[HtmlElement], - bottomLeft: Modifier[HtmlElement], - bottomRight: Modifier[HtmlElement], - farRight: Modifier[HtmlElement], - linkMods: Option[Modifier[Anchor]] = None -) - -object ListRow: - - given HtmlComponent[org.scalajs.dom.html.LI, ListRow] with - extension (r: ListRow) - def content: Modifier[HtmlElement] = - Seq( - cls := "block hover:bg-gray-50", - 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", - r.title - ), - div( - cls := "ml-2 flex-shrink-0 flex", - r.topRight - ) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - r.bottomLeft, - r.bottomRight - ) - ), - r.farRight - ) - ) - - def element: ReactiveHtmlElement[org.scalajs.dom.html.LI] = - val c = content - li( - r.linkMods match - case Some(m) => a(m, c) - case _ => div(c) - ) diff --git a/ui/src/ui/components/tailwind/list/PropList.scala b/ui/src/ui/components/tailwind/list/PropList.scala deleted file mode 100644 index 946ffaf..0000000 --- a/ui/src/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/ui/components/tailwind/list/RowNext.scala b/ui/src/ui/components/tailwind/list/RowNext.scala deleted file mode 100644 index 7e411c9..0000000 --- a/ui/src/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`("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 deleted file mode 100644 index 4ad12f4..0000000 --- a/ui/src/ui/components/tailwind/list/RowTag.scala +++ /dev/null @@ -1,22 +0,0 @@ -package works.iterative.ui.components.tailwind -package list - -import com.raquo.laminar.api.L.{*, given} - -object RowTag: - def apply(text: String, color: Color): 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(colorClass(color)), - cls := (color match { - case Color.red => "text-red-800 bg-red-100" - case Color.amber => "text-amber-800 bg-amber-100" - case Color.green => "text-green-800 bg-green-100" - case _ => "text-gray-800 bg-gray-100" - }), - text - ) diff --git a/ui/src/ui/components/tailwind/list/StackedList.scala b/ui/src/ui/components/tailwind/list/StackedList.scala deleted file mode 100644 index c8599f9..0000000 --- a/ui/src/ui/components/tailwind/list/StackedList.scala +++ /dev/null @@ -1,145 +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.nodes.ReactiveHtmlElement -import works.iterative.ui.components.headless.Items -import works.iterative.ui.components.headless.GroupedItems -import works.iterative.ui.components.headless.Toggle - -class StackedList[Item: AsListRow]: - import StackedList.* - def apply(items: List[Item]): ReactiveHtmlElement[dom.html.UList] = - ul( - role := "list", - cls := "divide-y divide-gray-200", - items.map(d => d.asListRow.element) - ) - - def withMod( - items: List[Item] - ): Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] = mods => - ul( - role := "list", - cls := "divide-y divide-gray-200", - mods, - items.map(d => d.asListRow.element) - ) - - def grouped(items: List[Item], groupBy: Item => String): List[HtmlElement] = - items.groupBy(groupBy).to(List).sortBy(_._1).map { case (c, i) => - withHeader(c)(withMod(i)) - } - -object StackedList: - def withHeader(header: String)( - content: Modifier[HtmlElement] => ReactiveHtmlElement[dom.html.UList] - ): HtmlElement = - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - header - ), - content(cls("relative")) - ) - -object StackedListWithRightJustifiedSecondColumn: - opaque type Title = ReactiveHtmlElement[dom.html.Paragraph] - opaque type Tag = ReactiveHtmlElement[dom.html.Paragraph] - opaque type LeftProp = ReactiveHtmlElement[dom.html.Paragraph] - opaque type RightProp = Div - - case class Item( - title: Title, - tag: Tag, - leftProps: Seq[LeftProp] = Nil, - rightProp: Option[RightProp] = None - ) - - def title(text: String): Title = - p( - cls := "text-sm font-medium text-indigo-600 truncate", - text - ) - - def tag(text: String, color: Color): Tag = - p( - cls("px-2 inline-flex text-xs leading-5 font-semibold rounded-full"), - text - ) - - def leftProp(text: String, icon: Option[SvgElement] = None): LeftProp = - p( - cls( - "mt-2 flex items-center text-sm text-gray-500 sm:mt-0 sm:ml-6" - ), - icon, - text - ) - - def rightProp(text: String, icon: Option[SvgElement] = None): RightProp = - div( - cls("mt-2 flex items-center text-sm text-gray-500 sm:mt-0"), - icon, - text - ) - - private def item(i: Item): Div = item(i, None) - - private def item(i: Item, extraClasses: Option[String]): Div = - div( - cls := "px-4 py-4 sm:px-6 items-center flex", - extraClasses.map(cls(_)), - div( - cls := "min-w-0 flex-1 pr-4", - div( - cls := "flex items-center justify-between", - i.title, - div(cls := "ml-2 flex-shrink-0 flex", i.tag) - ), - div( - cls := "mt-2 sm:flex sm:justify-between", - div( - cls("sm:flex"), - i.leftProps - ), - i.rightProp - ) - ) - ) - - private def headerFrame(text: String): Seq[HtmlElement] => Div = - content => - Toggle(ctx => - div( - cls("relative"), - h3( - cls( - "z-10 sticky top-0 border-t border-b border-gray-200 bg-gray-50 px-6 py-1 text-sm font-medium text-gray-500" - ), - text, - ctx.trigger - ), - children <-- ctx.toggle(content) - ) - ) - - private def frame: Seq[HtmlElement] => Div = - el => - div( - cls("bg-white shadow overflow-hidden sm:rounded-md"), - ul(role("list"), cls("divide-y divide-gray-200"), el) - ) - - def apply[A](f: this.type => A => Item): Items[A] = - Items(frame, item).contramap(f(this)) - - def grouped[A](f: this.type => A => Item): GroupedItems[String, A] = - GroupedItems( - frame, - k => Items(headerFrame(k), item(_, Some("relative"))).contramap(f(this)) - )