lucuma.ui.primereact.FormInputTextView.scala Maven / Gradle / Ivy
// Copyright (c) 2016-2023 Association of Universities for Research in Astronomy, Inc. (AURA)
// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause
package lucuma.ui.primereact
import cats.*
import cats.data.NonEmptyChain
import cats.syntax.all.*
import eu.timepit.refined.cats.*
import eu.timepit.refined.types.string.NonEmptyString
import japgolly.scalajs.react.*
import japgolly.scalajs.react.vdom.html_<^.*
import lucuma.core.validation.*
import lucuma.react.common.*
import lucuma.react.primereact.PrimeStyles
import lucuma.typed.primereact.components.Button as CButton
import lucuma.ui.input.AuditResult
import lucuma.ui.input.ChangeAuditor
import org.scalajs.dom.Element
import org.scalajs.dom.HTMLInputElement
import org.scalajs.dom.KeyCode
import org.scalajs.dom.document
import org.scalajs.dom.html
import scala.scalajs.js
import scalajs.js.JSConverters.*
/**
* FormInput component that uses a crystal View to share the content of the field
*/
final case class FormInputTextView[V[_], A](
id: NonEmptyString,
label: js.UndefOr[TagMod] = js.undefined,
units: js.UndefOr[String] = js.undefined,
preAddons: List[TagMod | CButton.Builder] = List.empty,
postAddons: List[TagMod | CButton.Builder] = List.empty,
size: js.UndefOr[PlSize] = js.undefined,
groupClass: js.UndefOr[Css] = js.undefined,
inputClass: js.UndefOr[Css] = js.undefined,
disabled: js.UndefOr[Boolean] = js.undefined,
error: js.UndefOr[NonEmptyString] = js.undefined,
placeholder: js.UndefOr[String] = js.undefined,
value: V[A],
validFormat: InputValidFormat[A] = InputValidSplitEpi.id,
changeAuditor: ChangeAuditor = ChangeAuditor.accept,
onTextChange: String => Callback = _ => Callback.empty,
onValidChange: FormInputTextView.ChangeCallback[Boolean] = _ => Callback.empty,
onFocus: js.UndefOr[ReactFocusEventFromInput => Callback] = js.undefined,
onBlur: FormInputTextView.ChangeCallback[EitherErrors[A]] = (_: EitherErrors[A]) =>
Callback.empty,
modifiers: Seq[TagMod] = Seq.empty
)(using val eq: Eq[A], val vl: ViewLike[V])
extends ReactFnProps(FormInputTextView.component):
def stringValue: String = value.get.foldMap(validFormat.reverseGet)
def addModifiers(modifiers: Seq[TagMod]) = copy(modifiers = this.modifiers ++ modifiers)
def withMods(mods: TagMod*) = addModifiers(mods)
def apply(mods: TagMod*) = addModifiers(mods)
def addPostAddons(addons: List[TagMod | CButton.Builder]) =
copy(postAddons = this.postAddons ++ addons)
def withPostAddons(addons: (TagMod | CButton.Builder)*) = addPostAddons(addons.toList)
object FormInputTextView {
type AnyF[_] = Any
protected type Props[V[_], A] = FormInputTextView[V, A]
protected type ChangeCallback[A] = A => Callback
// queries the dom based on id. Onus is on user to make id's unique.
private def getInputElement(id: NonEmptyString): CallbackTo[Option[html.Input]] =
CallbackTo(Option(document.querySelector(s"#${id.value}").asInstanceOf[html.Input]))
protected def buildComponent[V[_], A] = {
def audit(
auditor: ChangeAuditor,
value: String,
setDisplayValue: String => Callback,
inputElement: Option[html.Input],
setCursor: Option[(Int, Int)] => Callback,
lastKeyCode: Int
): CallbackTo[String] = {
val cursor: Option[(Int, Int)] = inputElement.map(i => (i.selectionStart, i.selectionEnd))
def setStateCursorFromInput(offset: Int): Callback =
setCursor(cursor.map { case (start, end) => (start + offset, end + offset) })
val cursorOffsetForReject: Int =
lastKeyCode match {
case KeyCode.Backspace => 1
case KeyCode.Delete => 0
case _ => -1
}
val c = cursor match {
case Some((start, _)) => start
case _ => value.length
}
auditor.audit(value, c) match {
case AuditResult.Accept =>
setCursor(none) >> setDisplayValue(value).as(value)
case AuditResult.NewString(newS, offset) =>
setStateCursorFromInput(offset) >> setDisplayValue(newS).as(newS)
case AuditResult.Reject =>
setStateCursorFromInput(cursorOffsetForReject) >> CallbackTo(value)
}
}
def validate(
displayValue: String,
validFormat: InputValidFormat[A],
onValidChange: FormInputTextView.ChangeCallback[Boolean],
cb: EitherErrors[A] => Callback = _ => Callback.empty
): Callback = {
val validated = validFormat.getValid(displayValue)
onValidChange(validated.isRight) >> cb(validated)
}
ScalaFnComponent
.withHooks[Props[V, A]]
.useStateBy(props => props.stringValue) // displayValue
.useState(none[(Int, Int)]) // cursor
.useRef(0) // lastKeyCode
.useRef(none[html.Input]) // inputElement
.useState(none[NonEmptyChain[NonEmptyString]]) // errors
.useEffectWithDepsBy((props, _, _, _, _, _) => props.stringValue)(
(_, displayValue, _, _, _, errors) =>
newValue => displayValue.setState(newValue) >> errors.setState(none)
)
.useEffectOnMountBy((props, displayValue, cursor, lastKeyCode, inputElement, _) =>
getInputElement(props.id) >>= (element =>
inputElement.set(element) >>
audit(
props.changeAuditor,
props.stringValue,
displayValue.setState,
element,
cursor.setState,
lastKeyCode.value
)
) >>= (value => validate(value, props.validFormat, props.onValidChange))
)
.useEffectBy((_, _, cursor, _, inputElement, _) =>
(for {
i <- inputElement.value
c <- cursor.value
} yield Callback(i.setSelectionRange(c._1, c._2))).orEmpty
)
.render { (props, displayValue, cursor, lastKeyCode, inputElement, errors) =>
import props.given
val errorChain: Option[NonEmptyChain[NonEmptyString]] =
(props.error.toOption, errors.value) match {
case (Some(a), Some(b)) => (a +: b).some
case (None, Some(b)) => b.some
case (Some(a), None) => NonEmptyChain(a).some
case (None, None) => none
}
val error: Option[String] = errorChain.map(_.mkString_(", "))
def handleTextChange(text: String): Callback =
audit(
props.changeAuditor,
text,
displayValue.setState,
inputElement.value,
cursor.setState,
lastKeyCode.value
).flatMap(newS =>
// First update the internal state, then call the outside listener
errors.setState(none) >>
props.onTextChange(newS) >>
validate(displayValue.value, props.validFormat, props.onValidChange)
)
val onTextChange: ReactEventFrom[HTMLInputElement & Element] => Callback =
(e: ReactEventFrom[HTMLInputElement & Element]) => handleTextChange(e.target.value)
val submit: Callback =
validate(
displayValue.value,
props.validFormat,
props.onValidChange,
{ validated =>
val validatedCB = validated match {
case Right(a) =>
// Only set if resulting A changed.
if (props.value.get.exists(_ =!= a))
props.value.set(a)
else // A didn't change, but redisplay formatted string.
displayValue.setState(props.stringValue)
case Left(e) =>
errors.setState(e.some)
}
validatedCB >> props.onBlur(validated)
}
)
val onKeyDown: ReactKeyboardEventFromInput => Callback = e =>
// TODO keyCode can be undefined (despite the facade). This happens when selecting a value in form auto-fill.
if (e.keyCode === KeyCode.Enter)
submit
else
lastKeyCode.set(e.keyCode) >> cursor.setState(none)
val onPaste: ReactClipboardEvent => Callback = e =>
val input = e.target.asInstanceOf[org.scalajs.dom.HTMLInputElement]
val current = input.value
val start = input.selectionStart
val end = input.selectionEnd
val prefix = current.substring(0, start)
val suffix = current.substring(end)
val paste = e.clipboardData.getData("text")
val result = s"$prefix$paste$suffix"
val normalized =
props.validFormat.getValid(result).map(props.validFormat.reverseGet).toOption
val update = normalized.foldMap(handleTextChange)
e.preventDefaultCB >> update
FormInputText(
id = props.id,
label = props.label,
units = props.units,
size = props.size,
groupClass = props.groupClass,
inputClass =
error.map(_ => PrimeStyles.Invalid).orEmpty |+| props.inputClass.toOption.orEmpty,
tooltip = error.map(s => s: VdomNode).orUndefined,
disabled = props.disabled,
preAddons = props.preAddons,
postAddons = props.postAddons,
onFocus = props.onFocus,
onBlur = _ => submit,
onChange = onTextChange,
onKeyDown = onKeyDown,
placeholder = props.placeholder,
value = displayValue.value,
modifiers = (^.onPaste ==> onPaste) +: props.modifiers
)
}
}
protected val component = buildComponent[AnyF, Any]
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy