Skip to content

Instantly share code, notes, and snippets.

@zv3
Last active December 22, 2018 23:00
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save zv3/d569d7856cdd9fa7435d201eacf5bcf3 to your computer and use it in GitHub Desktop.
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.
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("&times;")
)
)
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("&nbsp;"))
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
}
}
/**
* 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); }
}
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("&times;")
)
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
}
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