Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
674 changes: 501 additions & 173 deletions kyo-ui/README.md

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions kyo-ui/js-wasm/src/main/scala/kyo/internal/DomBackend.scala
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ private[kyo] object DomBackend:
html <- HtmlRenderer.render(ui, Seq.empty)
_ <- Sync.defer(container.innerHTML = html)
_ <- applyJsProps(container)
_ <- Sync.defer(beginAnimationsSync(container))
exchange = LocalExchange(root)
dispatch <- ReactiveUI.subscribe(root, exchange)
// Single-consumer drain owned by the ambient page Scope: every JS event effect is run by a
Expand Down Expand Up @@ -68,6 +69,7 @@ private[kyo] object DomBackend:
val updated = document.querySelector(s"""[data-kyo-path="$pathAttr"]""")
if updated != null then
applyJsPropsSync(updated)
beginAnimationsSync(updated)
end if
}
}
Expand Down Expand Up @@ -113,6 +115,28 @@ private[kyo] object DomBackend:
private def hasAnyKyoProp(el: dom.Element): Boolean =
(0 until el.attributes.length).exists(i => el.attributes(i).name.startsWith("data-kyo-prop-"))

/** Start every freshly-inserted SMIL animation under `root`.
*
* Chart transition `<animate>` elements use `begin="indefinite"` so they do not auto-play against the
* shared SVG document timeline (which would make a post-load update snap to the frozen `to` value).
* Calling `beginElement()` after the node is inserted starts the tween relative to now. The call is
* deferred one animation frame so the SMIL engine has registered the newly inserted elements; a node
* that was already replaced again by then throws and is ignored.
*/
private def beginAnimationsSync(root: dom.Element): Unit =
val anims = root.querySelectorAll("animate,animateTransform,animateMotion")
if anims.length > 0 then
discard(dom.window.requestAnimationFrame { (_: Double) =>
var i = 0
while i < anims.length do
try anims(i).asInstanceOf[scalajs.js.Dynamic].beginElement()
catch case _: Throwable => ()
i += 1
end while
})
end if
end beginAnimationsSync

/** Set up capture-phase event delegation on document.body. */
private def setupEventDelegation(dispatch: (Seq[String], UIEvent) => Boolean < Async, events: Channel[Unit < Async])(using
Frame
Expand Down
1,483 changes: 1,483 additions & 0 deletions kyo-ui/shared/src/main/scala/kyo/Chart.scala

Large diffs are not rendered by default.

5 changes: 2 additions & 3 deletions kyo-ui/shared/src/main/scala/kyo/Style.scala
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package kyo

import scala.annotation.tailrec
import scala.language.implicitConversions

/** An immutable, ordered list of style properties to attach to a [[kyo.UI]] element.
*
Expand All @@ -16,7 +15,7 @@ import scala.language.implicitConversions
*
* - **Pseudo-states**: `hover`, `focus`, `active`, and `disabled` each nest a *child* `Style` that applies only in that interaction state.
* - **Value clamping**: sizing values are clamped to non-negative (and to a sensible minimum where a zero would be invalid, e.g. font
* size and border width), opacity/filter ratios are clamped to their valid ranges, and color channels are clamped on construction.
* size and border width), opacity/filter ratios are clamped to their valid ranges, and color components are clamped on construction.
* - **Introspection**: the encoded props are pattern-matchable, so `find`, `filter`, and `without` can query or strip them.
*
* IMPORTANT: merge and dedup key on the property *kind*, so a later same-kind write silently replaces the earlier one rather than combining
Expand Down Expand Up @@ -574,7 +573,7 @@ object Style:
*
* `Color` is a sealed ADT with private constructors, so colors are built through the [[kyo.Style.Color]] companion: the `hex`, `rgb`,
* and `rgba` factories, or the named constants (`Color.white`, `Color.blue`, ...). Construction validates and clamps: `rgb`/`rgba`
* clamp channels to `[0, 255]` and alpha to `[0, 1]`, while `hex` returns a `Maybe` because an invalid string yields `Absent` rather
* clamp components to `[0, 255]` and alpha to `[0, 1]`, while `hex` returns a `Maybe` because an invalid string yields `Absent` rather
* than throwing.
*
* @see
Expand Down
95 changes: 87 additions & 8 deletions kyo-ui/shared/src/main/scala/kyo/Svg.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package kyo
import kyo.UI.Ast.*
import kyo.internal.CssStyleRenderer
import kyo.internal.NumberFormat
import scala.language.implicitConversions

/** The SVG namespace: every SVG element factory, the sealed `SvgElement` AST, the
* capability traits, the typed value DSLs, and the constrained enums.
Expand Down Expand Up @@ -91,10 +92,22 @@ object Svg:
def feDisplacementMap(using Frame): FeDisplacementMap = FeDisplacementMap()

// ---- factories: SMIL animation ----
def animate(using Frame): Animate = Animate()

/** Builds a `<animate>` element that animates a single SVG attribute over time. */
def animate(using Frame): Animate = Animate()

/** Builds a `<animateTransform>` element that animates a transform attribute
* (translate, scale, rotate, skewX, skewY) on the parent element over time.
*/
def animateTransform(using Frame): AnimateTransform = AnimateTransform()
def animateMotion(using Frame): AnimateMotion = AnimateMotion()
def set(using Frame): SetAnim = SetAnim()

/** Builds a `<animateMotion>` element that moves the parent element along a path. */
def animateMotion(using Frame): AnimateMotion = AnimateMotion()

/** Builds a `<set>` animation element. Named `SetAnim` in the API to avoid clashing
* with `scala.collection.Set`; this factory is the canonical entry point.
*/
def set(using Frame): SetAnim = SetAnim()

// ---- AST root ----

Expand Down Expand Up @@ -216,7 +229,7 @@ object Svg:
case object CurrentColor extends Paint
final case class Color(value: Style.Color) extends Paint
final case class Ref(server: PaintServer) extends Paint
given Conversion[Style.Color, Paint] = Color(_)
implicit def colorToPaint(c: Style.Color): Paint = Color(c)
end Paint

/** A definition element that is referenced by id (gradient/pattern/clipPath/mask/marker/filter).
Expand Down Expand Up @@ -319,6 +332,20 @@ object Svg:
case Translate, Scale, Rotate, SkewX, SkewY
end TransformType

/** `animate` `calcMode`: the interpolation mode between keyframe values. Cases render to their lowercase SVG
* tokens (`Discrete -> "discrete"`, `Spline -> "spline"`, ...). `Spline` is the mode that activates
* `keySplines`.
*/
enum CalcMode derives CanEqual:
case Discrete, Linear, Paced, Spline
end CalcMode

private def calcModeToken(v: CalcMode): String = v match
case CalcMode.Discrete => "discrete"
case CalcMode.Linear => "linear"
case CalcMode.Paced => "paced"
case CalcMode.Spline => "spline"

// ---- SVG attribute bag ----

/** Typed presentation/geometry attributes for an SVG element. Separate from CSS
Expand Down Expand Up @@ -422,6 +449,9 @@ object Svg:
animDur: Maybe[String] = Absent,
animRepeatCount: Maybe[String] = Absent,
animBegin: Maybe[String] = Absent,
animCalcMode: Maybe[String] = Absent,
animKeyTimes: Maybe[String] = Absent,
animKeySplines: Maybe[String] = Absent,
animType: Maybe[TransformType] = Absent
)

Expand Down Expand Up @@ -1066,8 +1096,16 @@ object Svg:

// ---- SMIL animation: leaf elements placed inside a shape via ShapeChild ----

/** `<animate>`: animates a single attribute over time. Numeric `from`/`to` overloads
* format with the shared `NumberFormat.double` encoder (so `from(20.0)` is `"20"`).
/** `<animate>`: the SMIL element that animates a single attribute of its parent shape over time.
*
* Name the target attribute with `attributeName`, then describe the value trajectory. Use `from`/`to`
* for a two-point tween, or `values` for a multi-keyframe list. Numeric `from`/`to` overloads format
* with the shared `NumberFormat.double` encoder (so `from(20.0)` is `"20"`). Timing is set with `dur`,
* `begin`, and `repeatCount`.
*
* The interpolation curve is selected with `calcMode`; `CalcMode.Spline` is the mode that activates the
* `keySplines` control points, while `keyTimes` fixes when each keyframe value applies. This is a leaf
* child placed inside a shape via `ShapeChild`, not a standalone element.
*/
final case class Animate(svgAttrs: SvgAttrs = SvgAttrs(), attrs: Attrs = Attrs(), children: Chunk[UI] = Chunk.empty)(using
val frame: Frame
Expand All @@ -1084,8 +1122,34 @@ object Svg:
def dur(v: String): Animate = withSvg(svgAttrs.copy(animDur = Present(v)))
def repeatCount(v: String): Animate = withSvg(svgAttrs.copy(animRepeatCount = Present(v)))
def begin(v: String): Animate = withSvg(svgAttrs.copy(animBegin = Present(v)))

/** Sets the interpolation mode between keyframe values. `CalcMode.Spline` is the mode that activates
* `keySplines`; the other modes ignore it.
*/
def calcMode(v: CalcMode): Animate = withSvg(svgAttrs.copy(animCalcMode = Present(calcModeToken(v))))

/** Sets the `keyTimes` list (semicolon-separated fractions in `[0,1]`) defining when each keyframe value
* applies.
*/
def keyTimes(v: String): Animate = withSvg(svgAttrs.copy(animKeyTimes = Present(v)))

/** Sets the `keySplines` control points for spline interpolation. Requires `calcMode = CalcMode.Spline` to
* take effect.
*/
def keySplines(v: String): Animate = withSvg(svgAttrs.copy(animKeySplines = Present(v)))
end Animate

/** `<animateTransform>`: the SMIL element that animates a transform attribute on its parent element over
* time.
*
* The `type` setter selects the transform kind (`Translate`, `Scale`, `Rotate`, `SkewX`, or `SkewY`).
* The `from`/`to` values are the transform arguments formatted as a space-separated string per the SVG
* spec (e.g., `"0 0"` for translate). Use `attributeName` to specify which transform attribute to
* animate (typically `"transform"`).
*
* Timing is controlled by `dur`, `begin`, and `repeatCount`. Like `Animate`, this is a leaf child placed
* inside a shape via `ShapeChild`.
*/
final case class AnimateTransform(svgAttrs: SvgAttrs = SvgAttrs(), attrs: Attrs = Attrs(), children: Chunk[UI] = Chunk.empty)(using
val frame: Frame
) extends SvgElement:
Expand All @@ -1101,6 +1165,15 @@ object Svg:
def begin(v: String): AnimateTransform = withSvg(svgAttrs.copy(animBegin = Present(v)))
end AnimateTransform

/** `<animateMotion>`: the SMIL element that moves its parent element along a motion path over time.
*
* The `path` setter accepts a `PathData` value (the same `d` attribute used by `<path>`) describing the
* trajectory the element follows. Timing is controlled by `dur` and `repeatCount`.
*
* Unlike `Animate`, this element does not take `attributeName` because it always targets the implicit
* motion path transform of the parent. Like the other SMIL leaves, it is placed inside a shape via
* `ShapeChild`.
*/
final case class AnimateMotion(svgAttrs: SvgAttrs = SvgAttrs(), attrs: Attrs = Attrs(), children: Chunk[UI] = Chunk.empty)(using
val frame: Frame
) extends SvgElement:
Expand All @@ -1112,8 +1185,14 @@ object Svg:
def repeatCount(v: String): AnimateMotion = withSvg(svgAttrs.copy(animRepeatCount = Present(v)))
end AnimateMotion

/** `<set>`: sets an attribute to a value at a time. Named `SetAnim` to avoid clashing
* with `scala.collection.Set`; the factory is `Svg.set`.
/** `<set>`: the SMIL element that sets an attribute of its parent shape to a single value at a given time.
*
* Name the target attribute with `attributeName`, supply the value with `to`, and schedule the change
* with `begin`. Unlike `Animate`, there is no interpolation: the attribute jumps to the value at the
* scheduled time and holds it. This is the discrete-change counterpart to the tweening SMIL elements.
*
* Named `SetAnim` to avoid clashing with `scala.collection.Set`; the factory is `Svg.set`. Like the
* other SMIL leaves, it is placed inside a shape via `ShapeChild`.
*/
final case class SetAnim(svgAttrs: SvgAttrs = SvgAttrs(), attrs: Attrs = Attrs(), children: Chunk[UI] = Chunk.empty)(using
val frame: Frame
Expand Down
8 changes: 7 additions & 1 deletion kyo-ui/shared/src/main/scala/kyo/UI.scala
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,10 @@ object UI:
def aria(pairs: (String, String)*): Self =
withAttrs(attrs.copy(ariaAttrs = attrs.ariaAttrs ++ pairs))

/** Sets the WAI-ARIA `role` attribute (emitted as the bare `role="..."` HTML attribute). */
def role(v: String): Self =
withAttrs(attrs.copy(role = Present(v)))

// Data attributes (kyo- prefix is reserved)
/** Sets a single `data-*` attribute (the `name` is the suffix after `data-`).
*
Expand Down Expand Up @@ -760,7 +764,8 @@ object UI:
onScrollEvt: Maybe[WheelEvent => Any < Async] = Absent,
ariaAttrs: Map[String, String] = Map.empty,
dataAttrs: Map[String, String] = Map.empty,
jsProps: Map[String, String] = Map.empty
jsProps: Map[String, String] = Map.empty,
role: Maybe[String] = Absent
)

// ---- Non-element AST cases ----
Expand Down Expand Up @@ -1458,6 +1463,7 @@ object UI:
def disabled(v: Boolean): Dropdown = copy(disabled = Present(v))
def onChange(f: String => Any < Async): Dropdown = copy(onChange = Present(f))
end Dropdown

end Ast

end UI
Loading