Created
February 3, 2016 00:05
-
-
Save japgolly/09acfc630f6250b1648f to your computer and use it in GitHub Desktop.
scalajs-react + AutoComplete
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 shipreq.webapp.client.feature | |
import japgolly.scalajs.react._ | |
import japgolly.scalajs.react.extra._ | |
import org.scalajs.dom.html | |
import scala.scalajs.js | |
import shipreq.base.util.ScalaExt.EndoFn | |
import shipreq.base.util.Vector1 | |
import shipreq.webapp.client.jsfacade.TextComplete | |
import shipreq.webapp.client.lib.TextEditor | |
/** | |
* Usage | |
* ===== | |
* - Apply `install` in `ReactComponentB.configure`. | |
* - Satisfy `scalac`. In most cases you'll want to add `ForChild` to the component's props and use `installP`. | |
**/ | |
object AutoCompleteFeature { | |
implicit val reusabilityStrategies: Reusability[Strategies] = | |
Reusability.fn((a, b) => | |
(a eq b) || | |
a.corresponds(b)(_ eq _)) | |
type Strategies = Vector[TextComplete.Strategy] | |
implicit def autoLiftSingleStrategy[A](a: A)(implicit f: A => TextComplete.Strategy): Strategies = | |
Vector1(f(a)) | |
type ForChild = Strategies | |
/** | |
* Public only for unit-tests. For React components, use one of the `install…` methods. | |
*/ | |
def lowLevelInstall[E <: html.Element](target : E, | |
strategies: TextComplete.Strategies, | |
onUpdate : => (String => Callback)) | |
(implicit E: TextEditor.OfType[E]): Callback = | |
Callback.ifTrue(strategies.nonEmpty, Callback { | |
val tgt = js.Dynamic.global.$(target) | |
TextComplete(tgt, strategies) | |
TextComplete.onSelect(tgt) { | |
onUpdate(E.value(target)).runNow() | |
} | |
}) | |
def lowLevelDestroy(node: html.Element): Callback = | |
Callback { | |
val $n = js.Dynamic.global.$(node) | |
TextComplete.destroy($n) | |
} | |
def install[P, S, B, N <: TopNode, E <: html.Element]( | |
getNode : CompScope.DuringCallbackM[P, S, B, N] => E, | |
strategies: (P, B) => ForChild, | |
onUpdate : (P, B) => String => Callback) | |
(implicit te: TextEditor.OfType[E]): EndoFn[ReactComponentB[P, S, B, N]] = | |
_.componentDidMount($ => Callback { | |
val n = getNode($) | |
te.focus(n) | |
te.select(n) | |
lowLevelInstall(n, strategies($.props, $.backend).toJsArray, onUpdate($.props, $.backend)).runNow() | |
}) | |
.componentDidUpdate(i => Callback { | |
val $ = i.$ | |
val p1 = i.prevProps | |
val p2 = $.props | |
val b = $.backend | |
val s1 = strategies(p1, b) | |
val s2 = strategies(p2, b) | |
if (s1 ~/~ s2) { | |
val n = getNode($) | |
lowLevelDestroy(n).runNow() | |
lowLevelInstall(n, s2.toJsArray, onUpdate($.props, b)).runNow() | |
} | |
}) | |
.componentWillUnmount($ => | |
lowLevelDestroy(getNode($))) | |
def installP[P, S, B, N <: TopNode, E <: html.Element]( | |
getNode : RefSimple[E], | |
strategies: P => ForChild, | |
onUpdate : P => String => Callback) | |
(implicit te: TextEditor.OfType[E]): EndoFn[ReactComponentB[P, S, B, N]] = | |
install(getNode(_).get, (p, _) => strategies(p), (p, _) => onUpdate(p)) | |
def installB[P, S, B, N <: TopNode, E <: html.Element]( | |
getNode : RefSimple[E], | |
strategies: B => ForChild, | |
onUpdate : B => String => Callback) | |
(implicit te: TextEditor.OfType[E]): EndoFn[ReactComponentB[P, S, B, N]] = | |
install(getNode(_).get, (_, b) => strategies(b), (_, b) => onUpdate(b)) | |
def installBP[P, S, B, N <: TopNode, E <: html.Element]( | |
getNode : RefSimple[E], | |
strategies: B => ForChild, | |
onUpdate : P => String => Callback) | |
(implicit te: TextEditor.OfType[E]): EndoFn[ReactComponentB[P, S, B, N]] = | |
install(getNode(_).get, (_, b) => strategies(b), (p, _) => onUpdate(p)) | |
} |
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 shipreq.webapp.client.jsfacade | |
import scalajs.js.{Function0 => JFn0, Function1 => JFn1, Function2 => JFn2, Function3 => JFn3, _} | |
import scalajs.js.{Any => JAny, Array => JArray, _} | |
import org.scalajs.dom.Event | |
object TextComplete { | |
final val eventSelect = "textComplete:select" | |
final val eventShow = "textComplete:show" | |
final val eventHide = "textComplete:hide" | |
type MatchType = RegExp // TODO | JFn1[String, RegExp] | |
type MatchFn = JFn1[String, RegExp] | |
@native sealed trait SearchFn[A] extends JAny | |
type ReplaceFn[A] = JFn2[A, Event, JAny] | |
/** | |
* @tparam A The type of data returned by the `search` function. | |
*/ | |
@native | |
sealed trait StrategyA[A] extends Object { | |
var `match`: MatchType = native | |
var search : SearchFn[A] = native | |
var replace: ReplaceFn[A] = native | |
var index : UndefOr[Int] = native | |
var template : UndefOr[JFn2[A, String, String]] = native | |
var cache : UndefOr[Boolean] = native | |
var context : UndefOr[JFn1[A, JAny]] = native // returns bool | string | regex | () → regex | |
var idProperty: UndefOr[String] = native | |
} | |
@native | |
sealed trait Callback[A] extends JAny { | |
def apply(result: JArray[A], stillSearching: Boolean = false): Unit = native | |
} | |
def Options(): Options = (new Object).asInstanceOf[Options] | |
@native | |
sealed trait Options extends Object { | |
var appendTo: JAny = native // $('body') | |
var height: UndefOr[Int] = native // undefined | |
var maxCount: Int = native // 10 | |
var placement: String = native // '' | |
var header: UndefOr[JAny] = native // undefined. T = string | () → string | |
var footer: UndefOr[JAny] = native // undefined. T = string | () → string | |
var zIndex: String = native // '100' | |
var debounce: UndefOr[Int] = native // undefined. T = milliseconds | |
//var adapter: UndefOr[JAny] = native // undefined | |
var className: String = native // '' | |
} | |
type Strategy = StrategyA[_] | |
def search2[A](f: (String, Callback[A]) => Unit): SearchFn[A] = | |
(f: JFn2[String, Callback[A], Unit]).asInstanceOf[SearchFn[A]] | |
def search3[A](f: (String, Callback[A], JArray[String]) => Unit): SearchFn[A] = | |
(f: JFn3[String, Callback[A], JArray[String], Unit]).asInstanceOf[SearchFn[A]] | |
def replace1[A](f: (A, Event) => String): ReplaceFn[A] = | |
(f: JFn2[A, Event, String]).asInstanceOf[ReplaceFn[A]] | |
def replace2[A](f: (A, Event) => (String, String)): ReplaceFn[A] = { | |
val f2 = (a: A, e: Event) => { | |
val r = f(a, e) | |
JArray(r._1, r._2) | |
} | |
(f2: JFn2[A, Event, JArray[String]]).asInstanceOf[ReplaceFn[A]] | |
} | |
object Strategy { | |
@inline private implicit class DictionaryExt(val self: Dictionary[JAny]) extends AnyVal { | |
@inline def updated(key: String, value: JAny): Dictionary[JAny] = { | |
self.update(key, value) | |
self | |
} | |
} | |
def pattern(pattern: String, flags: String = "", index: UndefOr[Int] = undefined): B1 = | |
regexp(new RegExp(pattern, flags), index) | |
def regexp(r: RegExp, index: UndefOr[Int] = undefined): B1 = { | |
val d = Dictionary.empty[JAny].updated("match", r: MatchType) | |
index.foreach(d.update("index", _)) | |
new B1(d) | |
} | |
def apply(f: String => RegExp, index: UndefOr[Int] = undefined): B1 = { | |
val d = Dictionary.empty[JAny].updated("match", f: MatchFn) | |
index.foreach(d.update("index", _)) | |
new B1(d) | |
} | |
final class B1(val o: Dictionary[JAny]) extends AnyVal { | |
def apply [A](f: SearchFn[A]) : B2[A] = new B2[A](o.updated("search", f)) | |
def search2[A](f: (String, Callback[A]) => Unit): B2[A] = apply(TextComplete search2 f) | |
def search3[A](f: (String, Callback[A], JArray[String]) => Unit): B2[A] = apply(TextComplete search3 f) | |
def search [A](f: String => Seq[A]) : B2[A] = search2((t, c) => c(JArray(f(t): _*))) | |
} | |
final class B2[A](val o: Dictionary[JAny]) extends AnyVal { | |
def apply (f: ReplaceFn[A]) : B3[A] = new B3(o.updated("replace", f)) | |
def replace (f: A => String) : B3[A] = replaceE((a, _) => f(a)) | |
def replaceE (f: (A, Event) => String) : B3[A] = apply(TextComplete replace1 f) | |
def replace2 (f: A => (String, String)) : B3[A] = replaceE2((a, _) => f(a)) | |
def replaceE2(f: (A, Event) => (String, String)): B3[A] = apply(TextComplete replace2 f) | |
} | |
final class B3[A](val o: Dictionary[JAny]) extends AnyVal { | |
def update(key: String, value: JAny): B3[A] = { | |
o.update(key, value) | |
this | |
} | |
def index (i: Int ): B3[A] = update("index", i) | |
def cache (i: Boolean ): B3[A] = update("cache", i) | |
def template (i: (A, String) => String): B3[A] = update("template", i: JFn2[A, String, String]) | |
def contextB (i: A => Boolean ): B3[A] = update("context", i: JFn1[A, Boolean]) | |
def contextS (i: A => String ): B3[A] = update("context", i: JFn1[A, String]) | |
def contextR (i: A => RegExp ): B3[A] = update("context", i: JFn1[A, RegExp]) | |
def idProperty(i: String ): B3[A] = update("idProperty", i) | |
@inline def result: StrategyA[A] = | |
o.asInstanceOf[StrategyA[A]] | |
} | |
@inline implicit def autoResultFromB3[A](b: B3[A]): StrategyA[A] = b.result | |
} | |
type Strategies = JArray[Strategy] | |
@inline implicit def autoSingletonStrategy[A](s: StrategyA[A]): Strategies = { | |
val a: Strategies = new JArray(1) | |
a(0) = s | |
a | |
} | |
@inline def Strategies(ss: Strategy*): Strategies = | |
JArray(ss: _*) | |
type JQuerySel = Dynamic | |
// def apply(target: JQuerySel, strategy: Strategy[_]): JQuerySel = | |
// apply(target, JArray(strategy)) | |
// | |
// def apply(target: JQuerySel, strategy: Strategy[_], options: UndefOr[Options]): JQuerySel = | |
// apply(target, JArray(strategy), options) | |
def apply(target: JQuerySel, strategies: Strategies): JQuerySel = | |
target.textcomplete(strategies) | |
def apply(target: JQuerySel, strategies: Strategies, options: UndefOr[Options]): JQuerySel = | |
target.textcomplete(strategies, options) | |
/** If you want to "stop autocompleting". */ | |
def destroy(target: JQuerySel): JQuerySel = | |
target.textcomplete("destroy") | |
/** Fired with the selected value when a dropdown is selected. */ | |
def onSelect(target: JQuerySel, f: (Event, String, Strategy) => Unit): JQuerySel = | |
target.on(Dynamic.literal(eventSelect -> (f: JFn3[Event, String, Strategy, Unit]))) | |
/** Fired with the selected value when a dropdown is selected. */ | |
def onSelect(target: JQuerySel, f: (Event, String) => Unit): JQuerySel = | |
target.on(Dynamic.literal(eventSelect -> (f: JFn2[Event, String, Unit]))) | |
/** Fired with the selected value when a dropdown is selected. */ | |
def onSelect(target: JQuerySel, f: (Event) => Unit): JQuerySel = | |
target.on(Dynamic.literal(eventSelect -> (f: JFn1[Event, Unit]))) | |
/** Fired with the selected value when a dropdown is selected. */ | |
def onSelect(target: JQuerySel)(f: => Unit): JQuerySel = | |
target.on(Dynamic.literal(eventSelect -> ((() => f): JFn0[Unit]))) | |
/** Fired when a dropdown is shown. */ | |
def onShow(target: JQuerySel, f: Event => Unit): JQuerySel = | |
target.on(Dynamic.literal(eventShow -> (f: JFn1[Event, Unit]))) | |
/** Fired when a dropdown is shown. */ | |
def onShow(target: JQuerySel)(f: => Unit): JQuerySel = | |
target.on(Dynamic.literal(eventShow -> ((() => f): JFn0[Unit]))) | |
/** Fired when a dropdown is hidden. */ | |
def onHide(target: JQuerySel, f: Event => Unit): JQuerySel = | |
target.on(Dynamic.literal(eventHide -> (f: JFn1[Event, Unit]))) | |
/** Fired when a dropdown is hidden. */ | |
def onHide(target: JQuerySel)(f: => Unit): JQuerySel = | |
target.on(Dynamic.literal(eventHide -> ((() => f): JFn0[Unit]))) | |
// =================================================================================================================== | |
// Additional niceties | |
type Query[A] = String => Stream[A] | |
/** | |
* Prevents auto-complete when the search term is empty. | |
* Prevents showing all options without criteria. | |
* | |
* Note that you can prevent this in your `match` regex. | |
*/ | |
def ignoreEmptyTerm[A](f: Query[A]): Query[A] = | |
term => | |
if (term.isEmpty) | |
Stream.empty | |
else | |
f(term) | |
/** | |
* Prevents auto-complete when the only result just what the user already has typed. | |
*/ | |
def ignorePerfectMatch[A](query: Query[A])(perfectMatch: (String, A) => Boolean): Query[A] = | |
term => { | |
val r = query(term) | |
if (r.lengthCompare(1) == 0 && perfectMatch(term, r.head)) | |
Stream.empty | |
else | |
r | |
} | |
def ignorePerfectMatchStr(query: Query[String]): Query[String] = | |
ignorePerfectMatch(query)(_ == _) | |
/** | |
* Normalises term and options before comparison. | |
* | |
* @param options Pre-sorted options. | |
*/ | |
def normalisedStringQuery[A](norm: String => String, cmp: (String, String) => Boolean, options: Stream[String]): Query[String] = { | |
val os = options.map(s => (norm(s), s)) | |
term => { | |
val t2 = norm(term) | |
os.filter(o => cmp(o._1, t2)).map(_._2) | |
} | |
} | |
/** | |
* Matches options containing the search string, where case is ignored. | |
* | |
* @param options Pre-sorted options. | |
*/ | |
def caseInsensitiveContains(options: Stream[String]): Query[String] = | |
ignorePerfectMatchStr( | |
normalisedStringQuery(_.toLowerCase, _ contains _, options)) | |
/** | |
* Matches options containing the search string, where case is ignored. | |
* | |
* @param options Pre-sorted options. | |
*/ | |
def caseInsensitiveStartsWith(options: Stream[String]): Query[String] = | |
ignorePerfectMatchStr( | |
normalisedStringQuery(_.toLowerCase, _ startsWith _, options)) | |
} |
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 shipreq.webapp.client.lib | |
import japgolly.scalajs.react._, vdom.prefix_<^._ | |
import org.scalajs.dom.html | |
sealed abstract class TextEditor { | |
type Dom <: html.Element | |
@inline final def asImplicit: TextEditor.OfType[Dom] = this | |
def tag: ReactTagOf[Dom] | |
def multiLine: Boolean | |
def value(d: Dom): String | |
def focus(d: Dom): Unit | |
def select(d: Dom): Unit | |
final def singleLine = !multiLine | |
} | |
object TextEditor { | |
type OfType[D <: html.Element] = TextEditor {type Dom = D} | |
implicit object Input extends TextEditor { | |
override type Dom = html.Input | |
override def tag = <.input(^.`type` := "text") | |
override def multiLine = false | |
override def value (d: Dom) = d.value | |
override def focus (d: Dom) = d.focus() | |
override def select(d: Dom) = d.select() | |
} | |
implicit object TextArea extends TextEditor { | |
override type Dom = html.TextArea | |
override def tag = <.textarea | |
override def multiLine = true | |
override def value (d: Dom) = d.value | |
override def focus (d: Dom) = d.focus() | |
override def select(d: Dom) = d.select() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment