Last active
December 22, 2018 23:00
-
-
Save zv3/d569d7856cdd9fa7435d201eacf5bcf3 to your computer and use it in GitHub Desktop.
A lightweight port of Jedwatson's react-select written in Scala using jagpolly's scalajs-react wrapper library.
This file contains hidden or 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 client.components.select | |
import japgolly.scalajs.react._ | |
import japgolly.scalajs.react.extra.{LogLifecycle, Reusability, Px} | |
import japgolly.scalajs.react.vdom.prefix_<^._ | |
import org.scalajs.dom | |
import org.scalajs.dom.ext.KeyCode | |
import org.scalajs.dom.raw.HTMLInputElement | |
object Select { | |
val selectTextInputRef = Ref[HTMLInputElement]("selectTextInput") | |
case class Props[A]( | |
allowMulti : Boolean = false, | |
filterFn : Option[(A, String) => Boolean] = None, | |
inputValue : Seq[A] = Seq.empty, | |
isClearable : Boolean = true, | |
isDisabled : Boolean = false, | |
isLoading : Boolean = false, | |
isSearchable : Boolean = true, | |
name : String = "", | |
onChangeInputText : Option[String => Callback] = None, | |
onChangeInputValue : Option[Seq[A] => Callback] = None, | |
onClickInputTagLabel : Option[A => Callback] = None, | |
onBlurInput : Option[() => Callback] = None, | |
onFocusInput : Option[() => Callback] = None, | |
onScrollDDMenuToBottom : Option[() => Callback] = None, | |
options : Seq[A] = Seq.empty, | |
optionRenderer : Option[A => ReactNode] = None, | |
optionValueRenderer : Option[A => ReactNode] = None, | |
optionIsDisabledFn : Option[A => Boolean] = None, | |
placeholder : Option[String] = None, | |
tabIndex : Int = 1, | |
labelRenderer : Option[A => ReactNode] = None, | |
valueRenderer : Option[A => ReactNode] = None | |
) | |
case class State[A]( | |
isFocused : Boolean = false, | |
ddMenuIsOpen : Boolean = false, | |
isLoading : Boolean = false, | |
isPseudoFocused : Boolean = false, | |
textInputValue : String = "", | |
filteredOptions : Seq[A] = Seq.empty, | |
focusedOption : Option[A] = None | |
) | |
class Backend[A]($: BackendScope[Props[A], State[A]]) { | |
private var _focusedOption: Option[A] = None | |
private var _openAfterFocus: Boolean = false | |
private implicit val reusableProps = Reusability.fn[Props[A]]((p1, p2) => | |
(p1.onFocusInput == p2.onFocusInput) && | |
(p1.onBlurInput == p2.onBlurInput) && | |
(p1.onChangeInputValue == p2.onChangeInputValue) && | |
(p1.onScrollDDMenuToBottom == p2.onScrollDDMenuToBottom) && | |
(p1.onChangeInputText == p2.onChangeInputText) && | |
(p1.isDisabled == p2.isDisabled) && | |
(p1.filterFn == p2.filterFn) | |
) | |
case class Callbacks(P: Props[A]) { | |
val onFocusInput = | |
Callback.log("[Select] onFocusInput") >> | |
$.modState { S => | |
S.copy( | |
isFocused = true, | |
ddMenuIsOpen = true | |
) | |
} >> | |
CallbackOption.liftOption(P.onFocusInput map(_().runNow())): Callback | |
// The onblur event occurs when an object loses focus. | |
val onBlurInput = | |
Callback.log("[Select] onBlurInput") >> | |
$.modState(_.copy( | |
textInputValue = "", | |
isFocused = false, | |
ddMenuIsOpen = false, | |
isPseudoFocused = false | |
)) >> | |
CallbackOption.liftOption(P.onBlurInput map(_().runNow())): Callback | |
val onClickInputDDMenuArrow: (ReactMouseEventI) => Callback = e => | |
Callback.when(!P.isDisabled && (e.eventType == "touchend" || (e.eventType == "mousedown" && e.nativeEvent.button == 0))) { | |
$.state.flatMap { S => | |
CallbackOption.require(S.ddMenuIsOpen) >> | |
e.preventDefaultCB >> | |
e.stopPropagationCB >> | |
closeDDMenu() | |
} | |
} | |
val onSetInputValue = (value: Seq[A]) => | |
CallbackOption.liftOption(P.onChangeInputValue map(_(value).runNow())) | |
val onScrollDDMenu: ReactMouseEventH => Callback = e => | |
CallbackOption.require( | |
e.target.scrollHeight > e.target.offsetHeight && | |
(e.target.scrollHeight - e.target.offsetHeight - e.target.scrollTop) == 0 | |
) >> | |
CallbackOption.liftOption(P.onScrollDDMenuToBottom map(_().runNow())) | |
val onChangeInputText = (e: ReactKeyboardEventI) => | |
$.modState(_.copy(ddMenuIsOpen = true, isPseudoFocused = false, textInputValue = e.target.value)) >> | |
CallbackOption.liftOption(P.onChangeInputText map (_(e.target.value).runNow())) | |
val filterOptionsFn: (A, String) => Boolean = P.filterFn.getOrElse((_, _) => true) | |
} | |
private val cbs = Px.cbA($.props).map(Callbacks) | |
private val SelectOptionComp = SelectOption[A]() | |
private val SelectInputTagComp = SelectInputTag[A]() | |
private val gainInputFocus = () => | |
selectTextInputRef($).tryFocus | |
private val closeDDMenu = () => | |
$.props.flatMap { P => | |
$.modState { S => | |
S.copy( | |
ddMenuIsOpen = false, | |
isPseudoFocused = S.isFocused && !P.allowMulti | |
) | |
} | |
} | |
private val clearInputValue = () => { | |
val cb = cbs.value() | |
cb.onSetInputValue(Seq.empty) >> | |
$.modState(_.copy(ddMenuIsOpen = false, textInputValue = "")) | |
} | |
private val onFocusDDMenuOption = (option: A) => | |
Callback.log("[Select] onFocusDDMenuOption") >> | |
$.modState(_.copy(focusedOption = Some(option))) | |
private val onClickInputClearIcon: (ReactMouseEventH) => Callback = e => | |
Callback.when(e.eventType == "touchend" || (e.eventType == "mousedown" && e.nativeEvent.button == 0)) { | |
e.preventDefaultCB >> | |
e.stopPropagationCB >> | |
clearInputValue() | |
} | |
private val onClickDDMenuOption = (option: A) => { | |
val cb = cbs.value() | |
Callback.log(s"[Select] onClickDDMenuOption: Adding ${option}") >> | |
$.props.flatMap { P => | |
if (P.allowMulti) { | |
cb.onSetInputValue(P.inputValue :+ option) >> | |
$.modState(_.copy(textInputValue = "")) | |
} else { | |
cb.onSetInputValue(Seq(option)) >> | |
$.modState { S => | |
S.copy( | |
ddMenuIsOpen = false, | |
textInputValue = "", | |
isPseudoFocused = S.isFocused | |
) | |
} | |
} | |
} | |
} | |
private val onClickControl: ReactMouseEvent => Callback = e => | |
for { | |
state <- $.state | |
props <- $.props | |
} yield { | |
e.preventDefaultCB >> | |
e.stopPropagationCB >> | |
(if (state.isFocused) | |
$.modState(_.copy( | |
ddMenuIsOpen = true, | |
isPseudoFocused = false | |
)) | |
else | |
gainInputFocus() | |
) | |
}.runNow() | |
private val onKeyDownControl: ReactKeyboardEventH => Callback = e => { | |
val cb = cbs.value() | |
for { | |
state <- $.state | |
props <- $.props | |
} yield { | |
CallbackOption.require(!props.isDisabled) >> | |
CallbackOption.keyCodeSwitch(e) { | |
// Remove previous input value | |
case KeyCode.Backspace if props.inputValue.nonEmpty && state.textInputValue.isEmpty => | |
cb.onSetInputValue(props.inputValue.dropRight(1)) | |
case KeyCode.Enter | KeyCode.Tab if state.ddMenuIsOpen => | |
_focusedOption.map(o => onClickDDMenuOption(o)).getOrElse(Callback.empty) | |
case KeyCode.Escape if state.ddMenuIsOpen => | |
closeDDMenu() | |
case KeyCode.Escape if props.isClearable => | |
clearInputValue() | |
} >> | |
e.preventDefaultCB | |
}.runNow() | |
} | |
// TODO: Fix the DD menu showing up when you click on an input tag remove icon btn | |
// it has to do with the focus gaining on the input field | |
private val onClickRemoveInputTag = (tag: A) => { | |
val cb = cbs.value() | |
$.props.flatMap { P => | |
cb.onSetInputValue(P.inputValue.filterNot(_ == tag)) | |
} | |
} | |
private def renderSpinner(P: Props[A]): ReactNode = | |
if (P.isLoading) | |
<.span(^.cls := "react-select__spinner-wrapper")( | |
<.span(^.cls := "react-select__spinner") | |
) | |
else | |
Seq.empty[ReactNode] | |
private def renderInputClearIcon(P: Props[A]): ReactNode = { | |
val shouldRender = !P.isDisabled && P.isClearable && P.inputValue.nonEmpty && !P.isLoading | |
if (shouldRender) | |
<.div( | |
^.cls := "react-select-input__clear-wrapper", | |
^.title := "Clear value", | |
^.onMouseDown ==> onClickInputClearIcon, | |
^.onTouchEnd ==> onClickInputClearIcon | |
)( | |
<.span( | |
^.cls := "react-select-input__clear-icon", | |
^.dangerouslySetInnerHtml("×") | |
) | |
) | |
else | |
Seq.empty[ReactNode] | |
} | |
private def renderInputDDArrow() = { | |
val cb = cbs.value() | |
<.span( | |
^.cls := "react-select-input__arrow-wrapper", | |
^.onMouseDown ==> cb.onClickInputDDMenuArrow, | |
^.onTouchEnd ==> cb.onClickInputDDMenuArrow | |
)( | |
<.span( | |
^.cls := "react-select-input__arrow-icon", | |
^.onMouseDown ==> cb.onClickInputDDMenuArrow, | |
^.onTouchEnd ==> cb.onClickInputDDMenuArrow | |
) | |
) | |
} | |
private def renderInputValue(S: State[A], P: Props[A], isOpen: Boolean)(implicit cb: Callbacks): ReactNode = { | |
if (S.textInputValue.isEmpty && P.inputValue.isEmpty) | |
<.div(^.cls := "react-select-input__placeholder", P.placeholder) | |
else if (P.allowMulti) | |
P.inputValue.map { v => | |
SelectInputTagComp.withKey(v.hashCode())( | |
SelectInputTag.Props( | |
isDisabled = P.isDisabled, | |
onClickLabel = P.onClickInputTagLabel, | |
onClickRemove = Some(onClickRemoveInputTag), | |
value = v | |
), <.div(P.labelRenderer.map(_(v))) | |
) | |
} | |
else if (S.textInputValue.isEmpty) | |
<.div(SelectInputTagComp( | |
SelectInputTag.Props[A]( | |
isDisabled = P.isDisabled, | |
onClickLabel = P.onClickInputTagLabel, | |
value = P.inputValue.head | |
), <.div(P.labelRenderer.map(_(P.inputValue.head))) | |
)) | |
else Seq.empty[ReactNode] | |
} | |
private def renderInput(S: State[A], P: Props[A])(implicit cb: Callbacks) = { | |
val cb = cbs.value() | |
val shouldRenderInput = !P.isDisabled && P.isSearchable | |
if (!shouldRenderInput) | |
if (P.allowMulti && P.inputValue.nonEmpty) | |
EmptyTag | |
else | |
<.div(^.cls := "react-select__input", ^.dangerouslySetInnerHtml(" ")) | |
else | |
<.div( | |
^.cls := "react-select__input", | |
^.onBlur --> cb.onBlurInput, | |
^.onFocus --> cb.onFocusInput, | |
<.input( | |
^.ref := selectTextInputRef, | |
^.tabIndex := P.tabIndex, | |
^.onChange ==> cb.onChangeInputText, | |
^.minWidth := 5, | |
^.value := S.textInputValue | |
// "width".reactStyle := S.textInputValue.length + 10 + "px" | |
) | |
) | |
} | |
private def renderDDMenu(P: Props[A], S: State[A], options: Seq[A])(implicit cb: Callbacks) = | |
if (options.nonEmpty) | |
options.zipWithIndex.map { case (option, i) => | |
val isSelected = P.inputValue.contains(option) | |
val isFocused = S.focusedOption.contains(option) | |
<.div( | |
SelectOptionComp.withKey(option.hashCode())(SelectOption.Props( | |
isSelected = isSelected, | |
isDisabled = P.optionIsDisabledFn.exists(_ (option)), | |
isFocused = isFocused, | |
option = option, | |
onSelect = Some(onClickDDMenuOption), | |
onFocus = Some(onFocusDDMenuOption) | |
), <.div(P.labelRenderer.map(_(option)))) | |
) | |
} | |
else | |
Seq(<.div(^.cls := "react-select-ddmenu__no-results")("No results")) | |
def render(props: Props[A], state: State[A]) = { | |
implicit val cb: Callbacks = cbs.value() | |
dom.console.log(s"[Select] render: S.isFocused = ${state.isFocused}") | |
val options = ( | |
if (props.allowMulti) | |
props.options.filterNot(props.inputValue.contains(_)) | |
else | |
props.options | |
) | |
.filter(cb.filterOptionsFn(_, state.textInputValue)) | |
val ddMenuIsOpen = | |
if (props.allowMulti && options.isEmpty && props.inputValue.nonEmpty && state.textInputValue.isEmpty) | |
false | |
else | |
state.ddMenuIsOpen | |
<.div(^.cls := "react-select")( | |
props.allowMulti ?= (^.cls := "react-select--multi"), | |
props.isDisabled ?= (^.cls := "is-disabled"), | |
state.isFocused ?= (^.cls := "is-focused"), | |
props.isLoading ?= (^.cls := "is-loading"), | |
ddMenuIsOpen ?= (^.cls := "is-open"), | |
state.isPseudoFocused ?= (^.cls := "is-pseudo-focused"), | |
props.isSearchable ?= (^.cls := "is-searchable"), | |
props.inputValue.nonEmpty ?= (^.cls := "has-value"), | |
<.div( | |
^.cls := "react-select__control", | |
^.onKeyDown ==> onKeyDownControl, | |
^.onClick ==> onClickControl | |
// ^.onTouchEnd ==> onClickControl | |
)( | |
<.div(^.cls := "react-select__input-wrapper")( | |
renderInputValue(state, props, ddMenuIsOpen), | |
renderInput(state, props) | |
), | |
renderSpinner(props), | |
renderInputClearIcon(props), | |
renderInputDDArrow() | |
), | |
if (ddMenuIsOpen) | |
<.div(^.cls := "react-select-ddmenu__wrapper")( | |
<.div(^.cls := "react-select-ddmenu", ^.onScroll ==> cb.onScrollDDMenu)(renderDDMenu(props, state, options)) | |
) | |
else EmptyTag | |
) | |
} | |
} | |
def apply[A]() = { | |
ReactComponentB[Props[A]]("Select") | |
.initialState[State[A]](State[A](ddMenuIsOpen = false)) | |
.renderBackend[Backend[A]] | |
.configure(LogLifecycle.short) | |
.build | |
} | |
} |
This file contains hidden or 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
/** | |
* React Select | |
* ============ | |
* Created by Jed Watson and Joss Mackison for KeystoneJS, http://www.keystonejs.com/ | |
* https://twitter.com/jedwatson https://twitter.com/jossmackison https://twitter.com/keystonejs | |
* MIT License: https://github.com/keystonejs/react-select | |
*/ | |
// Variables | |
// ------------------------------ | |
// common | |
$select-primary-color: #007eff; | |
// control options | |
$select-input-bg: #fff; | |
$select-input-bg-disabled: #f9f9f9; | |
$select-input-border-color: #ccc; | |
$select-input-border-radius: 4px; | |
$select-input-border-focus: $brandLink; | |
$select-input-border-width: 1px; | |
$select-input-height: 36px; | |
$select-input-internal-height: ($select-input-height - ($select-input-border-width * 2)); | |
$select-input-placeholder: #aaa; | |
$select-text-color: #333; | |
$select-link-hover-color: $select-input-border-focus; | |
$select-padding-vertical: 8px; | |
$select-padding-horizontal: 10px; | |
// menu options | |
$select-menu-zindex: 1; | |
$select-menu-max-height: 200px; | |
$select-option-color: lighten($select-text-color, 20%); | |
$select-option-focused-color: $select-text-color; | |
$select-option-focused-bg: fade($select-primary-color, 8%); | |
$select-option-disabled-color: lighten($select-text-color, 60%); | |
$select-noresults-color: lighten($select-text-color, 40%); | |
// clear "x" button | |
$select-clear-size: floor(($select-input-height / 2)); | |
$select-clear-color: #999; | |
$select-clear-hover-color: #D0021B; // red | |
$select-clear-width: ($select-input-internal-height / 2); | |
// arrow indicator | |
$select-arrow-color: #999; | |
$select-arrow-color-hover: #666; | |
$select-arrow-width: 5px; | |
// loading indicator | |
$select-loading-size: 16px; | |
$select-loading-color: $select-text-color; | |
$select-loading-color-bg: $select-input-border-color; | |
// multi-select item | |
$select-item-font-size: .9em; | |
$select-item-bg: $brandLink; | |
$select-item-color: $btn-primary-color; | |
$select-item-border-color: $btn-primary-border; | |
$select-item-hover-color: darken($select-item-color, 5%); | |
$select-item-hover-bg: darken($select-item-bg, 5%); | |
$select-item-disabled-color: #333; | |
$select-item-disabled-bg: #fcfcfc; | |
$select-item-disabled-border-color: darken($select-item-disabled-bg, 10%); | |
$select-item-border-radius: 2px; | |
$select-item-gutter: 5px; | |
$select-item-padding-horizontal: 5px; | |
$select-item-padding-vertical: 2px; | |
// | |
// Control | |
// ------------------------------ | |
// Mixins | |
// focused styles | |
@mixin react-select--focus-state($color) { | |
border-color: $color; | |
//box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 0 3px fade(@color, 10%); | |
} | |
// "classic" focused styles: maintain for legacy | |
//.Select-focus-state-classic() { | |
// border-color: @select-input-border-focus lighten(@select-input-border-focus, 5%) lighten(@select-input-border-focus, 5%); | |
// box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1), 0 0 5px -1px fade(@select-input-border-focus,50%); | |
//} | |
// Utilities | |
@mixin size($width, $height) { | |
width: $width; | |
height: $height; | |
} | |
@mixin square($size) { | |
@include size($size, $size); | |
} | |
@mixin border-top-radius($radius) { | |
border-top-right-radius: $radius; | |
border-top-left-radius: $radius; | |
} | |
@mixin border-right-radius($radius) { | |
border-bottom-right-radius: $radius; | |
border-top-right-radius: $radius; | |
} | |
@mixin border-bottom-radius($radius) { | |
border-bottom-right-radius: $radius; | |
border-bottom-left-radius: $radius; | |
} | |
@mixin border-left-radius($radius) { | |
border-bottom-left-radius: $radius; | |
border-top-left-radius: $radius; | |
} | |
// Vendor Prefixes | |
@mixin animation($animation) { | |
-webkit-animation: $animation; | |
-o-animation: $animation; | |
animation: $animation; | |
} | |
@mixin box-sizing($boxmodel) { | |
-webkit-box-sizing: $boxmodel; | |
-moz-box-sizing: $boxmodel; | |
box-sizing: $boxmodel; | |
} | |
@mixin rotate($degrees) { | |
-webkit-transform: rotate($degrees); | |
-ms-transform: rotate($degrees); // IE9 only | |
-o-transform: rotate($degrees); | |
transform: rotate($degrees); | |
} | |
@mixin transform($transform) { | |
-webkit-transform: $transform; | |
-moz-transform: $transform; | |
-ms-transform: $transform; | |
transform: $transform; | |
} | |
// | |
// Spinner | |
// ------------------------------ | |
@mixin Select-spinner($size, $orbit, $satellite) { | |
@include animation(Select-animation-spin 400ms infinite linear); | |
@include square($size); | |
box-sizing: border-box; | |
border-radius: 50%; | |
border: floor(($size / 8)) solid $orbit; | |
border-right-color: $satellite; | |
display: inline-block; | |
position: relative; | |
} | |
.react-select { | |
position: relative; | |
// preferred box model | |
&, | |
& div, | |
& input, | |
& span { | |
@include box-sizing(border-box); | |
} | |
// handle disabled state | |
&.is-disabled > .react-select__control { | |
background-color: $select-input-bg-disabled; | |
&:hover { | |
box-shadow: none; | |
} | |
} | |
&.is-disabled .react-select-input__arrow-wrapper { | |
cursor: default; | |
pointer-events: none; | |
} | |
} | |
// base | |
.react-select__control { | |
background-color: $select-input-bg; | |
border-color: lighten($select-input-border-color, 5%) $select-input-border-color darken($select-input-border-color, 10%); | |
border-radius: $select-input-border-radius; | |
border: $select-input-border-width solid $select-input-border-color; | |
color: $select-text-color; | |
cursor: default; | |
min-height: $select-input-height; | |
outline: none; | |
overflow: hidden; | |
position: relative; | |
width: 100%; | |
display: flex; | |
//flex-flow: row wrap; | |
&:hover { | |
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.06); | |
} | |
} | |
.is-searchable { | |
&.is-open > .react-select__control { | |
cursor: text; | |
} | |
} | |
.is-open > .react-select__control { | |
@include border-bottom-radius( 0 ); | |
background: $select-input-bg; | |
border-color: darken($select-input-border-color, 10%) $select-input-border-color lighten($select-input-border-color, 5%); | |
// flip the arrow so its pointing up when the menu is open | |
> .react-select-input__arrow-icon { | |
border-color: transparent transparent $select-arrow-color; | |
border-width: 0 $select-arrow-width $select-arrow-width; | |
} | |
} | |
.is-searchable { | |
&.is-focused:not(.is-open) > .react-select__control { | |
cursor: text; | |
} | |
} | |
.is-focused:not(.is-open) > .react-select__control { | |
@include react-select--focus-state($select-input-border-focus); | |
} | |
// placeholder | |
.react-select-input__placeholder, | |
:not(.react-select--multi) > .react-select__control .react-select-input-tag { | |
bottom: 0; | |
//flex: 1 1 auto; | |
color: $select-input-placeholder; | |
left: 0; | |
line-height: $select-input-internal-height; | |
padding-left: $select-padding-horizontal; | |
padding-right: $select-padding-horizontal; | |
position: absolute; | |
right: 0; | |
top: 0; | |
// crop text | |
max-width: 100%; | |
overflow: hidden; | |
text-overflow: ellipsis; | |
white-space: nowrap; | |
} | |
.has-value:not(.react-select--multi) > .react-select__control .react-select-input-tag, | |
.has-value.is-pseudo-focused:not(.react-select--multi) > .react-select__control .react-select-input-tag { | |
.react-select-input-tag__label { | |
color: $select-text-color; | |
} | |
a.react-select-input-tag__label { | |
cursor: pointer; | |
text-decoration: none; | |
&:hover, | |
&:focus { | |
color: $select-link-hover-color; | |
outline: none; | |
text-decoration: underline; | |
} | |
} | |
} | |
// the <input> element users type in | |
.react-select__input-wrapper { | |
display: flex; | |
flex-flow: row wrap; | |
flex: 1 1 auto; | |
} | |
.react-select__input { | |
//display: inline-block; | |
//display: block; | |
//float: left; | |
flex: 1 1 30px; | |
height: $select-input-internal-height; | |
padding-left: $select-padding-horizontal; | |
padding-right: $select-padding-horizontal; | |
vertical-align: middle; | |
> input { | |
width: 100%; | |
cursor: default; | |
background: none transparent; | |
box-shadow: none; | |
height: $select-input-internal-height; | |
border: 0 none; | |
font-family: inherit; | |
font-size: inherit; | |
margin: 0; | |
padding: 0; | |
outline: none; | |
display: inline-block; | |
-webkit-appearance: none; | |
.is-focused & { | |
cursor: text; | |
} | |
} | |
} | |
// fake-hide the input when the control is pseudo-focused | |
.has-value.is-pseudo-focused .react-select__input { | |
opacity: 0; | |
} | |
// fake input | |
.react-select__control:not(.is-searchable) .react-select__input { | |
outline: none; | |
} | |
// loading indicator | |
.react-select__spinner-wrapper { | |
flex: 0 0 auto; | |
align-self: center; | |
cursor: pointer; | |
display: table-cell; | |
position: relative; | |
text-align: center; | |
vertical-align: middle; | |
width: $select-loading-size; | |
} | |
.react-select__spinner { | |
@include Select-spinner($select-loading-size, $select-loading-color-bg, $select-loading-color); | |
vertical-align: middle; | |
} | |
// the little cross that clears the field | |
.react-select-input__clear-wrapper { | |
@include animation( Select-animation-fadeIn 200ms ); | |
color: $select-clear-color; | |
cursor: pointer; | |
flex: 0 0 auto; | |
//display: table-cell; | |
position: relative; | |
text-align: center; | |
vertical-align: middle; | |
align-self: center; | |
width: $select-clear-width; | |
&:hover { | |
color: $select-clear-hover-color; | |
} | |
} | |
.react-select-input__clear-icon { | |
display: inline-block; | |
font-size: $select-clear-size; | |
line-height: 1; | |
} | |
.react-select--multi .react-select-input__clear-wrapper { | |
width: $select-clear-width; | |
} | |
// arrow indicator | |
.react-select-input__arrow-wrapper { | |
align-self: center; | |
cursor: pointer; | |
//display: table-cell; | |
flex: 0 0 auto; | |
position: relative; | |
text-align: center; | |
vertical-align: middle; | |
width: ($select-arrow-width * 5); | |
padding-right: $select-arrow-width; | |
} | |
.react-select-input__arrow-icon { | |
border-color: $select-arrow-color transparent transparent; | |
border-style: solid; | |
border-width: $select-arrow-width $select-arrow-width ($select-arrow-width / 2); | |
display: inline-block; | |
height: 0; | |
width: 0; | |
} | |
.is-open .react-select-input__arrow-icon, | |
.react-select-input__arrow-wrapper:hover > .react-select-input__arrow-icon { | |
border-top-color: $select-arrow-color-hover; | |
} | |
// | |
// Multi-Select | |
// ------------------------------ | |
// Base | |
.react-select--multi { | |
// add margin to the input element | |
.react-select__input { | |
vertical-align: middle; | |
// border: 1px solid transparent; | |
margin-left: $select-padding-horizontal; | |
padding: 0; | |
} | |
// reduce margin once there is value | |
&.has-value .react-select__input { | |
margin-left: $select-item-gutter; | |
} | |
// Items | |
.react-select-input-tag { | |
height: 25px; | |
flex: 0 0 auto; | |
background-color: $select-item-bg; | |
border-radius: $select-item-border-radius; | |
border: 1px solid $select-item-border-color; | |
color: $select-item-color; | |
//display: inline-block; | |
font-size: $select-item-font-size; | |
line-height: 1.4; | |
margin-left: $select-item-gutter; | |
margin-top: $select-item-gutter; | |
vertical-align: top; | |
} | |
// common | |
.react-select-input-tag__icon, | |
.react-select-input-tag__label { | |
display: inline-block; | |
vertical-align: middle; | |
} | |
// label | |
.react-select-input-tag__label { | |
@include border-right-radius( $select-item-border-radius ); | |
cursor: default; | |
padding: $select-item-padding-vertical $select-item-padding-horizontal; | |
} | |
.react-select-input-tag__label--link { | |
color: $select-item-color; | |
cursor: pointer; | |
text-decoration: none; | |
&:hover { | |
text-decoration: underline; | |
} | |
} | |
// icon | |
.react-select-input-tag__icon { | |
cursor: pointer; | |
@include border-left-radius( $select-item-border-radius ); | |
border-right: 1px solid $select-item-border-color; | |
// move the baseline up by 1px | |
padding: ($select-item-padding-vertical - 1) $select-item-padding-horizontal ($select-item-padding-vertical + 1); | |
&:hover, | |
&:focus { | |
background-color: $select-item-hover-bg; | |
color: $select-item-hover-color; | |
} | |
&:active { | |
background-color: $select-item-border-color; | |
} | |
} | |
} | |
.react-select--multi.is-disabled { | |
.react-select-input-tag { | |
background-color: $select-item-disabled-bg; | |
border: 1px solid $select-item-disabled-border-color; | |
color: $select-item-disabled-color; | |
} | |
// icon | |
.react-select-input-tag__icon { | |
cursor: not-allowed; | |
border-right: 1px solid $select-item-disabled-border-color; | |
&:hover, | |
&:focus, | |
&:active { | |
background-color: $select-item-disabled-bg; | |
} | |
} | |
} | |
// Animation | |
// ------------------------------ | |
// fade in | |
@-webkit-keyframes Select-animation-fadeIn { | |
from { opacity: 0; } | |
to { opacity: 1; } | |
} | |
@keyframes Select-animation-fadeIn { | |
from { opacity: 0; } | |
to { opacity: 1; } | |
} | |
// | |
// Select Menu | |
// ------------------------------ | |
// wrapper around the menu | |
.react-select-ddmenu__wrapper { | |
// Unfortunately, having both border-radius and allows scrolling using overflow defined on the same | |
// element forces the browser to repaint on scroll. However, if these definitions are split into an | |
// outer and an inner element, the browser is able to optimize the scrolling behavior and does not | |
// have to repaint on scroll. | |
@include border-bottom-radius( $select-input-border-radius ); | |
background-color: $select-input-bg; | |
border: 1px solid $select-input-border-color; | |
border-top-color: mix($select-input-bg, $select-input-border-color, 50%); | |
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.06); | |
box-sizing: border-box; | |
margin-top: -1px; | |
max-height: $select-menu-max-height; | |
position: absolute; | |
top: 100%; | |
width: 100%; | |
z-index: $select-menu-zindex; | |
-webkit-overflow-scrolling: touch; | |
} | |
// wrapper | |
.react-select-ddmenu { | |
max-height: ($select-menu-max-height - 2px); | |
overflow-y: auto; | |
} | |
// options | |
.react-select-ddoption { | |
box-sizing: border-box; | |
color: $select-option-color; | |
cursor: pointer; | |
display: block; | |
padding: $select-padding-vertical $select-padding-horizontal; | |
&:last-child { | |
@include border-bottom-radius( $select-input-border-radius ); | |
} | |
&.is-focused { | |
background-color: $select-option-focused-bg; | |
color: $select-option-focused-color; | |
} | |
&.is-disabled { | |
color: $select-option-disabled-color; | |
cursor: default; | |
} | |
} | |
// no results | |
.react-select-ddmenu__no-results { | |
box-sizing: border-box; | |
color: $select-noresults-color; | |
cursor: default; | |
display: block; | |
padding: $select-padding-vertical $select-padding-horizontal; | |
} | |
@keyframes Select-animation-spin { | |
to { transform: rotate(1turn); } | |
} | |
@-webkit-keyframes Select-animation-spin { | |
to { -webkit-transform: rotate(1turn); } | |
} |
This file contains hidden or 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 client.components.select | |
import japgolly.scalajs.react._ | |
import japgolly.scalajs.react.extra.{Reusability, Px} | |
import japgolly.scalajs.react.vdom.prefix_<^._ | |
object SelectInputTag { | |
private val noOPFn = (a: Any) => () | |
case class Props[A]( | |
isDisabled : Boolean = false, | |
onClickLabel : Option[A => Callback] = None, | |
onClickRemove : Option[A => Callback] = None, | |
value : A | |
) | |
class Backend[A]($: BackendScope[Props[A], Unit]) { | |
implicit val reusableProps = Reusability.fn[Props[A]]((p1, p2) => | |
(p1.onClickLabel == p2.onClickLabel) && (p1.onClickRemove == p2.onClickRemove) | |
) | |
case class Callbacks(P: Props[A]) { | |
val onMouseDown: ReactMouseEventH => Option[Callback] = e => | |
P.onClickLabel.map { cb => | |
Callback.when(e.eventType == "touchend" || (e.eventType == "mousedown" && e.nativeEvent.button == 0)) { | |
e.stopPropagationCB >> | |
cb(P.value) | |
} | |
} | |
val onClickRemoveIcon: ReactMouseEventH => Option[Callback] = e => | |
P.onClickRemove.map { cb => | |
Callback.when(e.eventType == "touchend" || (e.eventType == "mousedown" && e.nativeEvent.button == 0)) { | |
e.preventDefaultCB >> | |
e.stopPropagationCB >> | |
cb(P.value) | |
} | |
} | |
} | |
val cbs = Px.cbA($.props).map(Callbacks) | |
private def renderRemoveIcon(P: Props[A]): ReactNode = { | |
val cb = cbs.value() | |
if (!P.isDisabled && P.onClickRemove.isDefined) | |
<.span( | |
^.cls := "react-select-input-tag__icon react-select-input-tag__icon--remove", | |
^.onMouseDown ==>? cb.onClickRemoveIcon, | |
^.onTouchEnd ==>? cb.onClickRemoveIcon, | |
^.dangerouslySetInnerHtml("×") | |
) | |
else Seq.empty[ReactNode] | |
} | |
private def renderLabel(P: Props[A], PC: PropsChildren) = { | |
val cb = cbs.value() | |
if (P.onClickLabel.isDefined) | |
<.a( | |
^.cls := "react-select-input-tag__label react-select-input-tag__label--link", | |
^.onMouseDown ==>? cb.onMouseDown, | |
^.onTouchEnd ==>? cb.onMouseDown, | |
PC | |
) | |
else | |
<.span(^.cls := "react-select-input-tag__label", PC) | |
} | |
def render(props: Props[A], PC: PropsChildren) = { | |
<.div(^.cls := "react-select-input-tag")( | |
renderRemoveIcon(props), | |
renderLabel(props, PC) | |
) | |
} | |
} | |
def apply[A]() = | |
ReactComponentB[Props[A]]("SelectInputTag") | |
.renderBackend[Backend[A]] | |
.build | |
} |
This file contains hidden or 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 client.components.select | |
import japgolly.scalajs.react._ | |
import japgolly.scalajs.react.extra.{LogLifecycle, Reusability, Px} | |
import japgolly.scalajs.react.vdom.prefix_<^._ | |
import org.scalajs.dom | |
object SelectOption { | |
case class Props[A]( | |
isDisabled : Boolean = false, | |
isFocused : Boolean = false, | |
isSelected : Boolean = false, | |
onSelect : Option[A => Callback] = None, | |
onFocus : Option[A => Callback] = None, | |
onUnFocus : Option[A => Callback] = None, | |
option : A, | |
renderFunc : Option[A => ReactElement] = None, | |
title : Option[String] = None | |
) | |
case class State(isFocused: Boolean = false) | |
class Backend[A]($: BackendScope[Props[A], State]) { | |
implicit val reusableProps = Reusability.fn[Props[A]]((p1, p2) => | |
(p1.onSelect == p2.onSelect) && | |
(p1.onFocus == p2.onFocus) && | |
(p1.onUnFocus == p2.onUnFocus) | |
) | |
case class Callbacks(P: Props[A]) { | |
val onMouseDown = (e: ReactMouseEventH) => | |
P.onSelect map { cb => | |
Callback.when(e.eventType == "touchend" || (e.eventType == "mousedown" && e.nativeEvent.button == 0)) { | |
Callback.log(s"[SelectOption] onMouseDown: ${P.option} Start") >> | |
e.preventDefaultCB >> | |
e.stopPropagationCB >> | |
cb(P.option) >> | |
Callback.log("[SelectOption] onMouseDown: End") | |
} | |
} : Option[Callback] | |
val onMouseEnter = (e: ReactMouseEventH) => | |
$.modState(_.copy(isFocused = true)) >> | |
CallbackOption.liftOption(P.onFocus map(_(P.option).runNow())) | |
val onMouseLeave = (e: ReactMouseEventH) => | |
$.modState(_.copy(isFocused = false)) >> | |
CallbackOption.liftOption(P.onUnFocus.map(_(P.option).runNow())) | |
} | |
private val blockEvent: (ReactEventI => Callback) = e => { | |
e.preventDefaultCB >> | |
CallbackOption.require(e.target.tagName == "A") >> | |
Callback(dom.window.open(e.target.getAttribute("href"))) | |
} | |
val cbs = Px.cbA($.props).map(Callbacks) | |
def render(props: Props[A], PC: PropsChildren) = { | |
val cb = cbs.value() | |
val cssSelectors: Seq[TagMod] = Seq(^.cls := "react-select-ddoption") ++ | |
(if (props.isDisabled) Seq(^.cls := "is-disabled") else Nil) ++ | |
(if (props.isFocused) Seq(^.cls := "is-focused") else Nil) ++ | |
(if (props.isSelected) Seq(^.cls := "is-selected") else Nil) | |
if (props.isDisabled) | |
<.div( | |
cssSelectors, | |
^.onMouseDown ==> blockEvent, | |
^.onClick ==> blockEvent, | |
PC | |
) | |
else | |
<.div( | |
cssSelectors, | |
^.onMouseDown ==>? cb.onMouseDown, | |
^.onMouseEnter ==> cb.onMouseEnter, | |
^.onTouchStart ==> cb.onMouseEnter, | |
^.onTouchCancel ==> cb.onMouseLeave, | |
^.onMouseLeave ==> cb.onMouseLeave, | |
^.title := props.title, | |
PC | |
) | |
} | |
} | |
def apply[A]() = | |
ReactComponentB[Props[A]]("SelectOption") | |
.initialState(State()) | |
.renderBackend[Backend[A]] | |
.build | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment