Skip to content

Commit afb8719

Browse files
Modernises Chosen, making heavy use of the cats syntax
1 parent eee314e commit afb8719

File tree

4 files changed

+107
-115
lines changed

4 files changed

+107
-115
lines changed

build.sbt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ lazy val `monadic-rx-cats` = crossProject
3939

4040
lazy val `tests` = project
4141
.enablePlugins(ScalaJSPlugin)
42-
.dependsOn(`monadic-html`)
42+
.dependsOn(`monadic-html`, `monadic-rx-catsJS`)
4343
.settings(noPublishSettings: _*)
4444
.settings(
4545
libraryDependencies += "org.scalatest" %%% "scalatest" % scalatest % "test",
Lines changed: 99 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,20 @@
1-
package mhtml.examples
1+
package examples
22

3-
import scala.scalajs.js
43
import scala.xml.Node
5-
4+
import cats.implicits._
5+
import mhtml.cats._
66
import mhtml._
7-
import org.scalajs.dom
8-
import org.scalajs.dom.Event
97
import org.scalajs.dom.KeyboardEvent
108
import org.scalajs.dom.ext.KeyCode
119
import org.scalajs.dom.raw.HTMLInputElement
1210

1311
/** Typeclass for [[Chosen]] select lists */
1412
trait Searcheable[T] {
1513
def show(t: T): String
16-
def isCandidate(query: String)(t: T): Boolean =
17-
show(t).toLowerCase().contains(query)
1814
}
1915

2016
object Searcheable {
17+
def apply[T](implicit ev: Searcheable[T]): Searcheable[T] = ev
2118
def instance[T](f: T => String): Searcheable[T] = new Searcheable[T] {
2219
override def show(t: T): String = f(t)
2320
}
@@ -29,124 +26,117 @@ object Searcheable {
2926
object Chosen {
3027
def underline(toUnderline: String, query: String): Node = {
3128
val index = toUnderline.toLowerCase.indexOf(query)
32-
if (index == -1) <span>{toUnderline}</span>
29+
if (index == -1) <span>{ toUnderline }</span>
3330
else {
34-
val before = toUnderline.substring(0, index)
35-
val after = toUnderline.substring(index + query.length)
31+
val before = toUnderline.substring(0, index)
32+
val after = toUnderline.substring(index + query.length)
3633
val matched = toUnderline.substring(index, index + query.length)
37-
<span>{before}<u>{matched}</u>{after}</span>
34+
<span>{ before }<u>{ matched }</u>{ after }</span>
3835
}
3936
}
4037

41-
def singleSelect[T](getCandidates: String => Rx[Seq[T]],
42-
placeholder: String = "",
43-
maxCandidates: Int = 10)(
44-
implicit ev: Searcheable[T]): (Node, Rx[Option[T]]) = {
45-
val id = "chosen-" + Math.random().toInt // to reference input dom
46-
val rxFocused = Var(false)
47-
val rxIndex = Var(0)
48-
val rxQuery = Var("")
49-
val rxSelected = Var(Option.empty[T])
50-
def setQuery(value: String): Unit = {
51-
rxQuery := value
52-
rxIndex := 0
53-
rxFocused := true
54-
}
55-
def setCandidate(candidate: T): Unit = {
56-
rxSelected := Some(candidate)
57-
rxFocused := false
58-
dom.document.getElementById(id) match {
59-
case input: HTMLInputElement => input.value = ev.show(candidate)
60-
case _ =>
61-
}
62-
}
63-
val rxCandidatesWithApp: Rx[(Node, Seq[T])] = for {
64-
query <- rxQuery
65-
index <- rxIndex
66-
allCandidates <- getCandidates(query)
67-
queryLower = query.toLowerCase
68-
} yield {
69-
val candidates =
70-
allCandidates.filter(ev.show(_).toLowerCase.contains(queryLower))
71-
val toDrop = Math.max(0, index - 3)
72-
val listItems =
73-
candidates.zipWithIndex.slice(toDrop, toDrop + maxCandidates).map {
74-
case (candidate, i) =>
75-
val cssClass =
76-
if (i == index) "chosen-highlight"
77-
else ""
78-
<li class={cssClass}>
79-
<a onclick={() => setCandidate(candidate)}>
80-
{underline(ev.show(candidate), queryLower)}
81-
</a>
82-
</li>
83-
}
84-
val itemsBefore: Node =
85-
if (toDrop == 0) <span></span>
86-
else <li>{toDrop.toString} more items...</li>
87-
val itemsAfter: Node = {
88-
val remaining =
89-
Math.max(0, candidates.length - (toDrop + maxCandidates))
90-
if (remaining == 0) <span></span>
91-
else <li>{remaining.toString} more items...</li>
38+
def singleSelect[T: Searcheable](candidates: Rx[List[T]], placeholder: String): (Node, Rx[Option[T]]) = {
39+
// These are the 5 streams of events involved in into this component.
40+
// By events, we mean that these are actually binded exactly once to
41+
// external sources (via :=). Given that scalac prohibits forward
42+
// references, and everything is composed functionally, this code is
43+
// guaranteed to have no infinite loops or race conditions.
44+
45+
val focusEvents: Var[Unit] = Var(())
46+
val queryEvents: Var[String] = Var("")
47+
val arrowPressedEvents: Var[Int] = Var(-1) // -1 → up; +1 → down
48+
val enterPressedEvents: Var[Unit] = Var(())
49+
val clickSelectionEvents: Var[Option[T]] = Var(None)
50+
51+
val maxCandidates: Int = 10
52+
val rxFilteredCandidates =
53+
(queryEvents |@| candidates).map { case (query, allCandidates) =>
54+
allCandidates.filter(Searcheable[T].show(_).toLowerCase.contains(query.toLowerCase))
9255
}
93-
val style = rxFocused.map { focused =>
94-
val display = if (focused) "" else "display: none"
95-
s"$display"
56+
57+
val rxIndex: Rx[Int] = (
58+
arrowPressedEvents.map(Option(_)) |+|
59+
focusEvents.map(_ => None) |@|
60+
rxFilteredCandidates
61+
).map { case (event, filteredCandidates) =>
62+
(event, filteredCandidates.size - 1)
63+
}.foldp(0) {
64+
case (last, (Some(delta), limit)) =>
65+
0 max (last + delta) min limit
66+
case _ => 0 // This reset corresponds to an acquisition of focus.
9667
}
97-
val div =
98-
<div class="chosen-options">
99-
<ul style={style}>
100-
{itemsBefore}
101-
{listItems}
102-
{itemsAfter}
68+
69+
val rxFocused: Rx[Boolean] = // LOL scalafmt
70+
focusEvents.map(_ => true ) |+|
71+
queryEvents.map(_ => true ) |+|
72+
arrowPressedEvents.map(_ => true ) |+|
73+
enterPressedEvents.map(_ => false) |+|
74+
clickSelectionEvents.map(_ => false)
75+
76+
val rxHighlightedCandidate: Rx[Option[T]] =
77+
(rxFilteredCandidates |@| rxIndex).map { case (cands, index) =>
78+
cands.zipWithIndex.find(_._2 == index).map(_._1)
79+
}.keepIf(_.nonEmpty)(None)
80+
81+
val rxSelected: Rx[Option[T]] =
82+
rxHighlightedCandidate.sampleOn(enterPressedEvents) |+| clickSelectionEvents
83+
84+
val rxChosenOptions: Rx[Node] =
85+
(rxIndex |@| rxFocused |@| queryEvents |@| rxFilteredCandidates).map {
86+
case(index, focus, query, fcand) =>
87+
def bounds(i: Int): Int = if (fcand.size > maxCandidates) i max 0 else 0
88+
val toDrop = bounds(index - 3)
89+
val remain = bounds(fcand.size - (toDrop + maxCandidates))
90+
val style = if (focus) None else Some("display: none")
91+
val itemsBefore = if (toDrop == 0) None else Some(<li>{ toDrop } more items...</li>)
92+
val itemsAfter = if (remain == 0) None else Some(<li>{ remain } more items...</li>)
93+
val listItems =
94+
fcand.zipWithIndex.slice(toDrop, toDrop + maxCandidates).map { case (candidate, i) =>
95+
<li class={ if (i == index) "chosen-highlight" else "" }>
96+
<a onclick={ () => clickSelectionEvents := Some(candidate) }>
97+
{ underline(Searcheable[T].show(candidate), query.toLowerCase) }
98+
</a>
99+
</li>
100+
}
101+
<div class="chosen-options">
102+
<ul style={ style }>
103+
{ itemsBefore }
104+
{ listItems }
105+
{ itemsAfter }
103106
</ul>
104107
</div>
105-
div -> candidates
106-
}
107-
val rxCandidates: Rx[Seq[T]] = rxCandidatesWithApp.map(_._2)
108-
val highlightedCandidate: Rx[T] = {
109-
val filtered = Var[T](rxCandidates.value.head)
108+
}
110109

111-
(for { index <- rxIndex; candidates <- rxCandidates } yield {
112-
candidates.zipWithIndex.find(_._2 == index).map(_._1)
113-
}).foreach(_.foreach(filtered.:=))
110+
var cancelableSelectionHandler = Cancelable.empty
111+
def selectionHandler(node: HTMLInputElement): Unit =
112+
cancelableSelectionHandler =
113+
rxSelected.impure.foreach(c => node.value = c.map(Searcheable[T].show).getOrElse(""))
114114

115-
filtered
116-
}
117-
// event handlers
118-
val onkeyup = { e: KeyboardEvent =>
115+
def onkeydown(e: KeyboardEvent): Unit =
119116
e.keyCode match {
120-
case KeyCode.Up =>
121-
rxIndex.update(x => Math.max(x - 1, 0))
122-
rxFocused := true
123-
case KeyCode.Down =>
124-
rxCandidates.foreach { candidates =>
125-
rxIndex.update(x => Math.min(x + 1, candidates.length - 1))
126-
}.cancel()
127-
rxFocused := true
128-
case KeyCode.Enter =>
129-
highlightedCandidate.foreach(setCandidate).cancel()
130-
case _ =>
117+
case KeyCode.Up => arrowPressedEvents := -1
118+
case KeyCode.Down => arrowPressedEvents := +1
119+
case KeyCode.Enter => enterPressedEvents := (())
120+
case _ => ()
131121
}
132-
()
133-
}
134-
val onblur = { _: Event =>
135-
js.timers.setTimeout(300)(rxFocused := false)
136-
()
137-
}
122+
123+
// @olfa: This implementation does not really makes sense as it's a tick
124+
// over the absolute clock. Proper implementation should start/cancel the
125+
// timeout on focus gained/lost.
126+
// def onblur(): Unit = { scala.scalajs.js.timers.setTimeout(1000)(rxFocused := false); () }
127+
138128
val app =
139129
<div class="chosen-wrapper">
140-
<input type="text"
141-
id={id}
142-
placeholder={placeholder}
143-
class="chosen-searchbar"
144-
onblur={onblur}
145-
onfocus={() => rxFocused := true}
146-
onkeydown={onkeyup}
147-
oninput={Utils.inputEvent(input => setQuery(input.value))}/>
148-
{rxCandidatesWithApp.map(_._1)}
130+
<input type="text" class="chosen-searchbar"
131+
mhtml-onmount = { selectionHandler _ }
132+
mhtml-onunmount = { cancelableSelectionHandler.cancel _ }
133+
placeholder = { placeholder }
134+
onfocus = { () => focusEvents := (()) }
135+
onkeydown = { onkeydown _ }
136+
oninput = { Utils.inputEvent(e => queryEvents := e.value) }/>
137+
{ rxChosenOptions }
149138
</div>
139+
150140
(app, rxSelected)
151141
}
152142
}

examples/src/main/scala/mhtml/examples/GithubAvatar.scala

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,7 @@ object GithubAvatar extends Example {
8181
case None => <div>Loading repos...</div>
8282
case Some(Success(repos)) =>
8383
val (searchList, active) =
84-
Chosen.singleSelect[GhRepo](_ => Var(repos),
85-
placeholder = "Search for repo...")
84+
Chosen.singleSelect[GhRepo](Var(repos), placeholder = "Search for repo...")
8685
<div>
8786
{searchList}
8887
{active.map(_.map(detailedRepo).getOrElse(<div></div>))}

examples/src/main/scala/mhtml/examples/SelectList.scala

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,15 @@ object SelectList extends Example {
4242
case Some(Success(response)) =>
4343
options := response.responseText.lines.collect {
4444
case country(code, name) => Country(name, code)
45-
}.toSeq
45+
}.toList
4646
case Some(Failure(e)) =>
47-
e.printStackTrace()
47+
// e.printStackTrace()
48+
// For offline hacking:
49+
options := (0 to 20).map(i => Country(util.Random.nextString(5), i.toString)).toList
4850
case _ =>
4951
}
50-
val (app, selected) = Chosen.singleSelect(_ => options)
52+
53+
val (app, selected) = Chosen.singleSelect(options, placeholder = "")
5154
val message: Rx[Node] = selected.map {
5255
case Some(x) => <div>
5356
<p>You selected: '{x.name}'</p>

0 commit comments

Comments
 (0)