Created
May 14, 2018 11:08
-
-
Save fizzy33/2c1d9c1ad03145256c6e0f3cc5f374d3 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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