Skip to content

Instantly share code, notes, and snippets.

@fizzy33
Created May 14, 2018 11:08
Show Gist options
  • Save fizzy33/2c1d9c1ad03145256c6e0f3cc5f374d3 to your computer and use it in GitHub Desktop.
Save fizzy33/2c1d9c1ad03145256c6e0f3cc5f374d3 to your computer and use it in GitHub Desktop.
package a8.scalajs.ui
import cats.implicits._
import mhtml._
import mhtml.implicits.cats._
import a8.common.Labeler
import a8.manna.model.ElementId
import a8.scalajs.Events
import org.scalajs.dom
import org.scalajs.dom.KeyboardEvent
import org.scalajs.dom.ext.KeyCode
import org.scalajs.dom.raw.{HTMLDivElement, HTMLInputElement}
import scala.scalajs.js
import scala.xml.Node
import a8.scalajs.predef._
/**
* Searchable select lists inspired by
* https://github.com/OlivierBlanvillain/monadic-html/blob/master/examples/src/main/scala/mhtml/examples/Chosen.scala
* and https://harvesthq.github.io/chosen/
* and https://select2.org/
*
*/
/*
TODO properly support multi-select and single select
TODO multi-select allow clearing of elements
*/
object Selector extends Logging {
sealed trait Event[+A]
object Events {
case object Noop extends Event[Nothing]
case class OpenPopup(filter: String = "") extends Event[Nothing]
case class ClosePopup(keepFocus: Boolean) extends Event[Nothing]
case class Select[A](values: Iterable[A] = Nil, append: Boolean) extends Event[A]
case class Filter(value: String) extends Event[Nothing] {
val lowerCase = value.toLowerCase.trim
}
case class Candidates[A](values: Iterable[A]) extends Event[A]
case object NextSelection extends Event[Nothing]
case object PreviousSelection extends Event[Nothing]
case object SelectSelection extends Event[Nothing]
}
object State {
sealed trait Index[+A]
case object Beginning extends Index[Nothing]
case object End extends Index[Nothing]
case object Unselected extends Index[Nothing]
case class CurrentElement[A](a: A) extends Index[A]
}
case class State[A](
index: State.Index[A] = State.Beginning,
popupVisible: Boolean = false,
filter: Events.Filter = Events.Filter(""),
selections: Iterable[A] = Nil,
allCandidates: Iterable[A] = Nil,
) (
implicit labeler: Labeler[A]
) {
import State._
lazy val filteredCandidates: Iterable[A] =
allCandidates.filter(c => labeler.label(c).toLowerCase.contains(filter.lowerCase))
def nextIndex(options: Options): State[A] = {
copy(
index =
index match {
case Beginning =>
options.allowClear.option(Unselected).getOrElse(firstIndex(End))
case End =>
End
case Unselected =>
firstIndex(End)
case CurrentElement(e) =>
moveToNextIndex(e, filteredCandidates, End)
}
)
}
def previousIndex(options: Options): State[A] = {
def unselected = options.allowClear.option(Unselected).getOrElse(Beginning)
copy(
index =
index match {
case Beginning =>
Beginning
case End =>
lastIndex(unselected)
case Unselected =>
Beginning
case CurrentElement(e) =>
moveToNextIndex(e, filteredCandidates.toList.reverse, unselected)
}
)
}
def moveToNextIndex(pos: A, candidates: Iterable[A], default: Index[A]): Index[A] = {
candidates
.dropWhile(_ != pos)
.drop(1)
.headOption
.map(CurrentElement.apply)
.getOrElse(default)
}
def firstIndex(default: Index[A]) = filteredCandidates.headOption.map(CurrentElement.apply).getOrElse(default)
def lastIndex(default: Index[A]) = filteredCandidates.lastOption.map(CurrentElement.apply).getOrElse(default)
}
object Options {
val default = Options()
}
case class Options(
id: Option[ElementId] = None,
allowClear: Boolean = false,
placeholder: String = "",
noSelectionText: Option[String] = None,
)
case class Widget[A, B](
view: scala.xml.Elem,
options: Options,
model: Rx[A],
events: Rx[Event[B]],
state: Rx[State[B]],
)
object impl {
def underline(toUnderline: String, filter: String): Node = {
val f = filter
val index = toUnderline.toLowerCase.indexOf(f)
if (index == -1) <span>{ toUnderline }</span>
else {
val before = toUnderline.substring(0, index)
val after = toUnderline.substring(index + f.length)
val matched = toUnderline.substring(index, index + f.length)
<span>{ before }<u>{ matched }</u>{ after }</span>
}
}
}
def singleSelect[A](
candidates: Rx[Iterable[A]],
valueSetter: Rx[Option[A]] = rxNone,
options: Options = Options.default,
)(
implicit
events: Events,
labeler: Labeler[A],
): Widget[Option[A],A] = {
val widget =
rawSelect(
candidates,
valueSetter.map(_.toIterable),
options,
false,
)
Widget(
view = widget.view,
model = widget.model.map(_.headOption),
options = widget.options,
events = widget.events,
state = widget.state,
)
}
def rawSelect[A](
candidates: Rx[Iterable[A]],
valuesSetter: Rx[Iterable[A]] = rxNil,
options: Options = Options.default,
multiSelect: Boolean,
)(
implicit
events: Events,
labeler: Labeler[A],
): Widget[Iterable[A],A] = {
import impl._
val eventsVar = Var(Events.Noop: Event[A])
val rxState =
eventsVar
.merge(valuesSetter.map(v => Events.Select(v, false)))
.merge(candidates.map(Events.Candidates.apply))
.foldp(State()) {
case (state, Events.Noop) =>
state
case (state, Events.OpenPopup(_)) =>
state.copy(
popupVisible = true,
filter = Events.Filter(""),
index = State.Beginning,
)
case (state, Events.ClosePopup(_)) =>
state.copy(
popupVisible = false,
)
case (state, Events.Select(v, append)) =>
val i = if ( append ) state.selections else Nil
state.copy(
selections = i ++ v,
popupVisible = false,
)
case (state, f: Events.Filter) =>
state.copy(
filter = f
)
case (state, Events.NextSelection) =>
state.nextIndex(options)
case (state, Events.PreviousSelection) =>
state.previousIndex(options)
case (state, Events.SelectSelection) =>
val selections =
state.index match {
case State.CurrentElement(e) =>
state.selections ++ List(e)
case _ =>
state.selections
}
state.copy(
selections = selections,
)
case (state, Events.Candidates(i)) =>
state.copy(
allCandidates = i
)
}
val rxPopupVisible = rxState.map(_.popupVisible).dropRepeats
val popupStyleVisible = rxPopupVisible.map(v => if (v) None else Some("display: none"))
val rxChosenOptions: Rx[Node] =
rxState.map { state =>
val unselect =
if (options.allowClear)
<li
onclick= { () =>
eventsVar := Events.Select(Nil, false)
}
class = { if (state.index == State.Unselected) "selector-highlight" else "" }
>
<a>{options.noSelectionText.getOrElse(if ( multiSelect ) "Clear Selections" else "No Selection")}</a>
</li>
else
emptyXml
val listItems =
state.filteredCandidates.map { candidate =>
val highlight =
state.index match {
case State.CurrentElement(e) if e == candidate =>
true
case _ =>
false
}
<li
class={ if (highlight) "selector-highlight" else "" }
onclick= { () =>
eventsVar := Events.Select(List(candidate), multiSelect)
}
>
<a>
{ underline(labeler.label(candidate), state.filter.lowerCase) }
</a>
</li>
}.toList
<ul>
{ unselect }
{ listItems }
</ul>
}
var rootElement: HTMLDivElement = null
lazy val selectorQueryInput =
rootElement.queryOne[HTMLInputElement](".selector-searchbar")
lazy val selectorValues =
rootElement.queryOne[HTMLDivElement](".selector-values")
val focusHandlerCancelable =
eventsVar
.foreach {
case Events.OpenPopup(filter) =>
// we just became visible
submit {
selectorQueryInput.focus()
selectorQueryInput.value = filter
}
case Events.ClosePopup(true) =>
submit {
selectorValues.focus()
}
case _ =>
}
// logRx(eventsVar.asRx)
// logRx(rxState)
// logRx(rxPopupVisible)
// logRx(popupStyleVisible)
var popupCancel = Cancelable.empty
var cancelable = Cancelable.empty
def initRootElement(node: dom.html.Div): Unit = {
rootElement = node
val tempCancelable =
rxPopupVisible.impure.foreach { poppedUp =>
popupCancel.cancel
if ( poppedUp ) {
popupCancel =
events.mousedown.foreachNext { e =>
e.target match {
case target: dom.Element =>
val ancestry = target.ancestry.toIndexedSeq.toJsArray
val clickInComponent = ancestry.exists(_ == node)
if ( !clickInComponent ) {
eventsVar := Events.ClosePopup(false)
}
case _ =>
}
}
} else {
popupCancel = Cancelable.empty
}
node.ancestry.find(_.hasClass("root-pane")).foreach(n =>
n.toggleClass("force-overflow", poppedUp)
)
node.parentNode.toggleClass("force-overflow", poppedUp)
}
cancelable = Cancelable { () =>
tempCancelable.cancel
popupCancel.cancel
focusHandlerCancelable.cancel
}
}
def onkeydown(e: KeyboardEvent): Unit = {
// console.log("onkeydown", e)
e.keyCode match {
case KeyCode.Up =>
eventsVar := Events.PreviousSelection
case KeyCode.Down =>
eventsVar := Events.NextSelection
case KeyCode.Enter =>
eventsVar := Events.SelectSelection
eventsVar := Events.ClosePopup(true)
case KeyCode.Escape =>
eventsVar := Events.ClosePopup(true)
case _ => ()
}
}
// logRx(popupStateEvents.asRx)
val rxSelected = rxState.map(_.selections).dropRepeats
def oninput(event: js.Dynamic): Unit = {
console.log("oninput", event)
eventsVar := Events.Filter(selectorQueryInput.value)
}
val app =
<div
class = { s"selector-wrapper ${multiSelect.option("multi-select").getOrElse("single-select)}")}" }
mhtml-onmount = { initRootElement _ }
mhtml-onunmount = { () => cancelable.cancel }
>
<div
class="selector-input"
onclick = {
rxPopupVisible.map({ poppedUp =>
if (poppedUp ) {
{ () =>
eventsVar := Events.ClosePopup(true)
}
} else {
{ () =>
eventsVar := Events.OpenPopup()
}
}
})
}
>
<div
class = "selector-values"
tabindex = "0"
onkeyup = { e: dom.KeyboardEvent =>
console.log("onkeyup", e)
e.keyCode matchopt {
case KeyCode.Up | KeyCode.Down | KeyCode.Space =>
eventsVar := Events.OpenPopup()
case _ =>
if ( e.charCode != 0 && e.key.charAt(0).isLetterOrDigit ) {
eventsVar := Events.OpenPopup(e.key)
}
}
()
}
>
{
rxSelected
.map { selectedItems =>
selectedItems
.map { a =>
<div class="selected-item">{labeler.label(a)}</div>
}
.toSeq
}
}
</div>
<div class = "selector-button"/>
</div>
<div
class = "selector-options"
style = { popupStyleVisible }
>
<input
type = "text"
class = "selector-searchbar"
placeholder = { options.placeholder }
onkeydown = { onkeydown _ }
oninput = { oninput _ }
/>
{ rxChosenOptions }
</div>
</div>
// logRx("", rxSelected)
Widget(app, options, rxSelected, eventsVar, rxState)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment