seed-rs / seed

A Rust framework for creating web apps
MIT License
3.81k stars 155 forks source link

Seed Styling System #412

Closed rebo closed 4 years ago

rebo commented 4 years ago

Quick Peek:

fn styled_button() -> Node<Msg> {

    let style = use_style(r#"{
         color:{{highlight||white}};
         background-color:{{primary||#3369ff}};
         text-align:center;
         position:relative;
        }
        &:active{
         background-color:{{muted||#0049dd}}}
    }"#);

    button![ style, "Click Me!" ]
}

I have been spending quite a bit of time lately working with various styling options within seed with a view to creating re-usable styled components. Currently options have several problems with creating re-usable views. Something which I have been working on with the existing experimental Seed hooks.

Existing options:

(+) - styles can be used inline directly within Seed and within shared components, styles are typed with St enum. (-) - lots and lots, primarily they fall outside of usual CSS styling which means pseudo selectors such as :hover and focus do not work. They override class based styling which means composition with anything is a nightmare. Basically unusable for any large projects.

(+) Proven old school solution, tags can have semantic classes and therefore style appropriately. (-) Styles cannot be used within components directly or within shared components, an external stylesheet is required. CSS needs to be in an external file which makes distribution and use awkward. Probable style clashes over clashing uses of class names.

(+) Actually pretty good, many of the solutions are super well developed and relatively easy to use. (-) Ties any developed Seed component directly to a specific framework. This greatly reduces the potential user of a Seed re-usable component or int he future a Seed UI system.

Therefore we need a solution that achieves a goal of an effective styling system for components whilst still giving developers as much freedom as possible.

My goals were to re-implement an existing javascript solution in Seed. With the following objectives

For inspiration I looked at https://styled-components.com/ , https://theme-ui.com/, https://emotion.sh/docs/introduction and https://xstyled.dev/.

This video is informative about the general principle for these frameworks.

https://www.youtube.com/watch?v=BkgU_-KGK9w

In my following post I will be explaining my current solution and how it can meet the above objectives.

MartinKavik commented 4 years ago

Just a few related projects for inspiration if you don't know them yet:

rebo commented 4 years ago

As you can see in the Quick Peek above, my current solution allows for CSS styles to be declared and used within a seed view function. Here it is again:

fn styled_button() -> Node<Msg> {

    let style = use_style(r#"{
         color:{{highlight||white}};
         background-color:{{primary||#3369ff}};
         text-align:center;
         position:relative;
        }
        &:active{
         background-color:{{muted||#0049dd}}}
    }"#);

    button![ style, "Click Me!" ]
}

The use_style() function takes the CSS string, assigns it a unique identifier injects the CSS under that identifier class name into the html <head>. In this way a specific component can be linked to a specific style.

This has the advantage of allowing styles to be declared at the same location as the view is constructed. Whilst at the same time using bog standard CSS.

Any CSS can be used here including all kinds of pseudo selectors etc. Currently the & identifier is used in a similar fashion to Sass/Less in order to refer to the component root selector. This allows &:hover pseudo selectors to work.

You will also see lines such as

  color:{{highlight||white}};

This shows the inbuilt theming, if the highlight color-style is available for the current theme then it is used, otherwise it defaults to white. This aspect is completely optional and theming does not need to be used.

As it stands the unique class names created are effectively hashes and therefore not human readable. However using use_named_style() will allow a named class to be used.

    let style = use_named_styles( "call_to_action_button", r#"{ color: ...}"#);

Advanced uses

Theming.

Currently what I have works in accordance with the Theme Specification which is used by a number of CSS frameworks.

The principle is that a "Theme object" is provided which is them used to style specific properties. For instance a theme objects .colors field will contain several definitions of colours, such as "primary", "highlight" and "muted" that will be common to all UI on a page. This will then be accessible in anything colour related in the CSS.

For instance,

   background-color : {{primary|| lightblue}};

primary points to the primary colour within the theme objects colour field. If that style is not available lightblue is used instead.

You may be thinking that this theme object would have to be passed around all the view functions and this might be annoying. Not so! Currently the following works.

use_theme( dark_theme , || div![
    p![
        span![ themed_button() ]
    ]
])

as long as themed_button() is called from within a use_theme() block it will have access to dark_theme.

It even works across multiple nested functions:

use_theme( dark_theme , || div![
    p![
        span![ display_form() ]
    ]
])
...

...
fn display_form -> Node<Msg> {
    div![
        p![
            ...
            // yes, even here, themed_button will still be able to access `dark_theme`
            themed_button()
        ]
    ]
}

This means you could surround your entire main view inside a use_theme() block and every thing can be themed from a single location:

fn main_view(model: &Model) -> Node<Msg> {
    let theme = if model.use_dark_theme {dark_theme} else {light_theme};
    use_theme(|| ...view nodes here... )
}

Css in view functions

The advantage of css in view functions is that it can be generated dynamically. This means that if you are creating a styled form you can provide options for specific colours in the signature:

fn styled_form(data: &Data, form_bg_color: &str) {
let style = use_style(|| format!("background-color: {}", form_bg_color);
...
}

or give the use the option of completely changing the css:

fn styled_form(data: &Data, css: Option<&str>) {
    let css = css.unwrap_or( "{ // default_css }");
    let style = use_style(|| css ) ;
...
}

Or even more crazy, bind the css argument to an At attribute on a html node. You then will have an attribute on all your styled nodes that lets you edit and perfect the style in the browser! No more recompilation required.

fn styled_card(data: &Data) {
    let css = use_state(||"//default css");
    div![bind(At::Custom("data-css"), css), ...]
}

then in your html via a browser (maybe only in debug mode):

<div id="main_card" data-css="...edit there here to update the css of this div">...</div>

Well there is loads more advanced use cases and options but this just gives you a flavour of the styling possibilities.

rebo commented 4 years ago

After a discussion on discord about variants. We have come up with the below api for variants. Thanks to Macocha for the advice.

In styled systems Variants are styled fragments that can be applied to a component. The idea is that a component can be designed then the use of the compent can select from a number of standard style variants.

For instance a button component may have a Danger style and an Action style. Instead of writing two different components one can have a single component but give the style choice of which variant.

Anyway here is the rough api:

enum Buttons {
    Danger,
    CallToAction
}

fn button_with_variants(variant: ButtonVariant ) -> Node<Msg>{

    // common styles
    let style = use_style(r#"{
          padding: 8px;
          margin: 4px;
        }
    "#);

    // style variants
    match variant {
        Buttons::Danger => extend_style(style, 
            r#"{color: white;
             background-color: red;}"#
        ),
        Buttons::CallToAction => extend_style(style,
            r#"{color: black;
             background-color: green;}"#
        )
    }

    div![style,"Click Me!"]
}
rebo commented 4 years ago

With the addition of "prop" typed styles we now have the makings of a very versatile system. All currently implemented and working as part of Seed hooks.

The Basics - per component css:

fn my_button()-> Node<Msg> {
    let style = use_css(r#"
        border-width: 2px;
        color: white;
        background-color: red;
    "#);

    button![style,"Click Me!"]
}

I think I will be dropping the theming support in css text to prevent this morphing into its own template language.

Optional typed properties using full length css properties


fn my_button_typed()-> Node<Msg> {
    let style = use_style(&[
        Style::with()
            .color("white")
            .border_width(2px)
            .background_color("red")
            .padding("4px")
    ]);

    button![style,"Click Me!"]
}

Theme Specification compliant themes (including theme scales):


fn view(_model: &Model) -> Node<Msg> {
    use_theme(Theme::dark(), ||
            my_button_theme_typed
    )
}

fn my_button_theme_typed()-> Node<Msg> {
    let style = use_style(&[
        Style::with()
            .color("white")
            .border_width(2px)
            .background_color(Th("primary","red"))  // named theme alias
            .padding(ThIdx(2,"2px")) // index into the second `space` scale
    ]);

    button![style,"Click Me!"]
}

'short-form' property names:

fn my_button_typed_short()-> Node<Msg> {
    let style = use_style(&[
        Style::with()
            .color("white")
            .bg_color("red")
            .px("24px")
            .py("8px")
            .m(ThIdx(1,"1px"))
    ]);

    button![style,"Click Me!"]
}

tailwind format style shortcuts:

fn my_button_typed_tw()-> Node<Msg> {
    let style = use_style(&[
        Style::with()
            .text_white()
            .bg_red_400() 
            .p_4()
            .inline_block() 
    ]);

    button![style,"Click Me!"]
}

Pseudo selectors are no problem:

fn my_button_theme_typed()-> Node<Msg> {
    let style = use_style(&[
        Style::with()
            .color("white")
            .background_color("red"),
        Style::hover()
            .color("red")
            .background_color("white")
    ]);

    button![style,"Click Me!"]
}

Mixing css with typed styles works:


fn my_button_extend()-> Node<Msg> {
    let style = use_css(r#"
        border-width: 2px;
        color: white;
        background-color: red;
    "#).extend(
        &[Style::with()
            .padding("4px")
            .margin("10px")]
    );
    button![style,"Click Me!"]
}

Typed or css Variants in the same function no problem!:

fn view() -> Node<Msg>{
    button_variant(Button::Danger)
}
...
...
fn button_variant(variant: Buttons ) -> Node<Msg>{
    let style= use_css(r#"{
    padding: 8px;
    margin: 4px;
    outline: none;
    border-radius: 4px;
}
{{self}}:focus{
    outline:none;
}

"#);

    let style = match variant {
        Buttons::Danger => style.extend(
r#"{
    color:white;
    background-color: red;
}

{{self}}:active{
    background-color:darkred;
}
"#
        ),
        Buttons::CallToAction => style.extend(
        &[ Style::with()
            .color("black")
            .background-color("lightgreen"),
        Style::active()
            .background-color("darkgreen"),
        ])
    };

    button![style,"Click Me!"]
}

Sles set by caller via &[S] "prop":

fn view(_model: &Model) -> Node<Msg> {
    use_theme(Theme::dark(), ||
            button_styled_with_args(&[
                Style::with()
                    .color("white")
                    .bg_color(Th("primary", "#DB3080"))
                    .b_width("2px")
                    .b_color(Th("primary", "#DB3080"))
                    .radius("3px")
                    .p("24px")
                    .m("8px")
                    .inline_block()
                    .transition("background-color 300ms, color 300ms"),
                Style::hover()
                    .color("white")
                    .bg_color(Th("primary_light", "palevioletred"))
            ])
    )
}
...

fn button_styled_with_args(styles: &[Style]) -> Node<Msg> {
    let style = use_style(r#"
        {
            display: inline-block;
            text-align: center;
        }
    "#).extend(styles);
    div![style, "Hello"]
}

re-usable styles(no-css overhead):

fn main_view() -> Node<Msg> {
    let shared_style = Style::with().color("white")
            .bg_color("red")
            .px("24px")
            .py("8px")
            .m(ThIdx(1, "2px"));

    div![
        element_a_with_style(shared_style),
        element_b_with_style(shared_style),
        element_c_with_style(shared_style),
        element_d_with_style(shared_style),
    ]
}

**Completely 'inline' ***

fn other_view()->Node<Msg> {
    button![
        use_style(Style::with().text_white().bg_color_red().px_4().py_2()), 
    "Click Me"]
}
thedodd commented 4 years ago

@rebo this is awesome!!! I'm finally going to be able to create a MaterialUI Seed component lib to replace my production JS app!!!

rebo commented 4 years ago

Glad you like it @thedodd I've been able to tidy it up a far bit after feedback from @MartinKavik so hang tight for an even more clear api.

I will be tidying up my code and uploading soon for people to test with.

rebo commented 4 years ago

Sneak peak of api improvements.

Hopefully it's super clean now:

fn simple_button() -> Node<Msg> {
    button![
        S.padding("2px").margin("3px").background_color("red"), 
        "Click Me"
    ]
}

An example using tailwind style methods and use of variants:


enum Shade {
    Light,
    Dark,
    Normal,
}

fn modern_button(variant: Shade) -> Node<Msg> {
    let base_style = S.p_4().m_2()
        .bg_red_600().text_gray_200()
        .radius("4px").inline_block();

    let style = match variant {
        Shade::Dark => base_style.bg_red_800().text_gray_400(),
        Shade::Light => base_style.bg_red_400().text_gray_100(),
        Shade::Normal => base_style,
    };

    button![style, "Click Me!"]
}

As you can see variants can be created simply by adding new rules to a base style.

Of course CSS style definitions work via css method:


fn simple_css_button() -> Node<Msg> {

    let style = S.css(r#"
        padding: 8px;
        margin: 4px;
        outline: none;
        border-radius: 4px;
    "#);

    button![ style, "Click Me"]
}
rebo commented 4 years ago

Closing in favour of #413 .