rjaros / kvision

Object oriented web framework for Kotlin/JS
https://kvision.io
MIT License
1.2k stars 67 forks source link

Support for global styles without relying on css classname literals #451

Closed reubenfirmin closed 1 year ago

reubenfirmin commented 1 year ago

One way to avoid recalculating styles on the fly (and yet keep styling in code) is to have a global styles class (or series of classes/objects) which is initialized.

For example:

class GlobalStyles {

    val cardStyle = Style(".card") {
            // stuff here
        }
    }
}

This gets "imported" as follows:

val styles = GlobalStyles()

...and now styles don't need to be regenerated each time components are rerendered. However, it's a bit messy, since it relies on the css classname to "link" the style to the component; i.e. to use it, of course, I have to have a widget that does:

class CardView(): Div() {
   init {
      addCssClass("card")
   }
}

I can't even break out "card" fully to a constant, because the css rule wants a dot in front of it, so I'd need to prepend with a dot when initializing the style (which is...ok, but still smelly.)

Can you come up with a cleaner way of "linking" the component to the global style? (This may be the same solution as #450, but I thought I would file separately in case there are two separate ways of tackling this.)

One possibility, though I don't know if inline/reifying/etc will work with the js compiler:

class GlobalStyles {

    val cardStyle = Style.for<CardView>() {
        // stuff here
    }
}

Of course, people would eventually want to replicate CSS selectors in code, so you could imagine craziness like:

class GlobalStyles {

    val cardStyle = Style.for<Heading1>().in<CardView> {
        // stuff here
    }
}

...but maybe this isn't so bad, as it's more readable than css selectors for somebody who isn't a browser developer.

Another simpler possibility:

class CardView {

    staticStyle {
         // stuff here
    }
}
reubenfirmin commented 1 year ago

Here's an example class that I use to both define and turn on and off css classes. It'd be awesome if this could be made cleaner (without the strings).

package com.example.model

import com.example.*
import com.example.App.Companion.debug
import com.example.view.canvas.CardView
import io.kvision.core.*
import io.kvision.core.Color.Companion.hex
import io.kvision.core.UNIT.*
import io.kvision.html.H1
import io.kvision.html.H2
import io.kvision.utils.em
import io.kvision.utils.px

const val CLASS_CARD = "card"
const val CLASS_ROUNDED = "rounded"
const val CLASS_OUTLINE_BLUE = "outline_blue" // TODO semantic name
const val CLASS_OUTLINE_GREEN = "outline_green"
const val CLASS_DECORATED = "decorated"
const val CLASS_HOVER = "hover"
const val CLASS_SELECTED = "selected"
const val CLASS_HEADING = "heading"
const val CLASS_INFO = "info"
const val CLASS_ALLDONE = "alldone"
const val CLASS_STRIKETHROUGH = "strikethrough"
const val CLASS_TASK = "task"

class CardStyle(val theme: Theme) {

    private val foundationCardStyle = Style() {
        color = hex(theme.cardFontColor)
        position = Position.RELATIVE
        whiteSpace = WhiteSpace.NORMAL
    }

    private val baseCardStyle =  inheritingStyle(parentStyle = foundationCardStyle) {
        margin = 10.px
        fontSize = 16.px
        lineHeight = 1.5.em
        opacity = 100.0
        padding = 10.px
        background = Background(hex(theme.cardBackground))
    }

    private val imageCardStyle = inheritingStyle(parentStyle = foundationCardStyle) {
        paddingRight = 15 to px
        paddingLeft = 15 to px
        paddingTop = 10 to px
        paddingBottom = 0 to px
        margin = 0 to px
    }

    private val roundedCardStyle = inheritingStyle(selector = ".$CLASS_CARD.$CLASS_ROUNDED", parentStyle = baseCardStyle) {
        borderRadius = 10.px
        boxShadow = BoxShadow(0.px, 8.px, 14.px, 0.px, Color.rgba(0,0,0,45))
    }

    private val linkCardStyle = inheritingStyle(parentStyle = roundedCardStyle) {
        textAlign = TextAlign.CENTER
        fontSize = 14 to px
        cursor = Cursor.POINTER
    }

    private val outlineCardStyleBlue = inheritingStyle(selector = ".$CLASS_CARD.$CLASS_OUTLINE_BLUE", parentStyle = linkCardStyle) {
        border = Border(2 to px, BorderStyle.SOLID, hex(theme.cardStripeFocused))
    }

    private val outlineCardStyleGreen = inheritingStyle(selector = ".$CLASS_CARD.$CLASS_OUTLINE_GREEN", parentStyle = linkCardStyle) {
        border = Border(2 to px, BorderStyle.SOLID, hex(theme.cardStripeDone))
    }

    private val decoratedCardStyle = inheritingStyle(selector = ".$CLASS_CARD.$CLASS_DECORATED", parentStyle = roundedCardStyle) {
        borderLeft = Border(10 to px, BorderStyle.SOLID, hex(theme.cardStripe))
    }

    private val decoratedCardStyleHover = inheritingStyle(selector = ".$CLASS_CARD.$CLASS_DECORATED.$CLASS_HOVER", parentStyle = decoratedCardStyle, pClass = PClass.HOVER) {
        background = Background(hex(theme.cardFocusedBackground))
        borderLeft = Border(10 to px, BorderStyle.SOLID, hex(theme.cardStripeFocused))
    }

    private val headingCard = Style(".$CLASS_CARD.$CLASS_HEADING") {
        margin = 10 to px
        borderLeft = Border(0 to px)
        background = Background(hex(theme.appBackground))
    }

    private val decoratedCardAllDoneStyle = inheritingStyle(selector = ".$CLASS_CARD.$CLASS_ALLDONE", parentStyle = decoratedCardStyle) {
        borderLeft = Border(10 to px, BorderStyle.SOLID, hex(theme.cardStripeDone))
    }

    private val selectedStyle = Style(".$CLASS_CARD.selected") {
        background = Background(hex(theme.cardFocusedBackground))
    }

    private val decoratedSelectedStyle = inheritingStyle(selector = ".$CLASS_CARD.$CLASS_DECORATED.$CLASS_SELECTED", parentStyle = selectedStyle) {
        borderLeft = Border(10 to UNIT.px, BorderStyle.SOLID, hex(theme.cardStripeFocused))
    }

    private val strikeThrough = Style(".$CLASS_STRIKETHROUGH") {
        textDecoration = TextDecoration(TextDecorationLine.LINETHROUGH)
        color = hex(theme.cardHeadingDone)
    }

    fun styleCard(card: Card, view: CardView, model: Model) {
        view.addCssClass(CLASS_CARD)
        if (view.selected) {
            view.addCssClass(CLASS_SELECTED)
        } else {
            view.removeCssClass(CLASS_SELECTED)
        }
        when {
            card.headingOnly() -> {
                view.addCssClass(CLASS_HEADING)
            }
            card.infoOnly() -> {
                view.addCssClass(CLASS_ROUNDED)
                view.addCssClass(CLASS_INFO)
            }
            else -> {
                if (card.done()) {
                    view.addCssClass(CLASS_ALLDONE)
                    // TODO this would be better as a ".class > h1,h2"
                    view.getChildren().filter { it is H1 || it is H2 }.forEach { child ->
                        child.addCssClass(CLASS_STRIKETHROUGH)
                    }
                } else {
                    view.addCssClass(CLASS_DECORATED)
                    view.addCssClass(CLASS_HOVER)
                }
                view.addCssClass(CLASS_TASK)
            }
        }

        if (card.isCanvasLink()) {
            applyCanvasLinkStyle(model, view)
        } else if (card.isImageLink()) {
            view.addCssStyle(imageCardStyle)
        }
    }

    fun applyCanvasLinkStyle(model: Model, view: CardView) {
        if (model.exists(view.card.linkedCanvas()!!)) {
            view.removeCssClass(CLASS_OUTLINE_GREEN)
            view.addCssClass(CLASS_OUTLINE_BLUE)
        } else {
            view.removeCssClass(CLASS_OUTLINE_BLUE)
            view.addCssClass(CLASS_OUTLINE_GREEN)
        }
    }
}
rjaros commented 1 year ago

But you don't have to rely on class selectors, because they are generally auto-generated. So instead of:

val cardStyle = Style(".card") {
    fontSize = 2.em
}

div {
    addCssClass("card")
    +"Some large text"
}

you should do:

val cardStyle = Style {
    fontSize = 2.em
}

div {
    addCssStyle(cardStyle)
    +"Some large text"
}

or even:

div {
    style {
        fontSize = 2.em
   }
    +"Some large text"
}
reubenfirmin commented 1 year ago

Yes I think that's right, though these styles need to be declared outside of the class, otherwise they get a new classname per instance of the class.

Check this out:

2022-11-15_04-36

Left arrow is me taking a hit by loading some global styles.

Middle three arrows is the app on initialization creating three help dialogs (which are initially hidden). They each contain the same boilerplate UI; however, as you can see below, each of them gets their own version of the Style for the X button, because it's created on class initialization.

2022-11-15_04-43 2022-11-15_04-42

I also had this problem on initialization of some buttons, shown at the right of first screenshot in this comment. Each of the buttons has their own style class too.

The above is following the pattern:

abstract class HelpTemplate(...) {
    val someWidgetStyle = Style {

    }

/// ...
     widget.addCssStyle(someWidgetStyle)
}

If I instead change to:

class HelpTemplateStyles() {
    val someWidgetStyle = Style {

    }
}

abstract class HelpTemplate(val styles: HelpTemplateStyles) {

/// ...
     widget.addCssStyle(styles.someWidgetStyle)
}

... then initialization instead looks like this:

2022-11-15_04-58

The three arrows on the right are the three different help templates, now using all of the common style objects. The arrow at the left is the initialization of the global styles for HelpTemplates. So, doing just this cut down rendering time by around 40ms. Incidentally I can't put these into a companion object, because I need to initialize them with the selected theme; and tangentially when the user changes theme, I force a full reload of the app, to reinitialize all of the global styles.