seed-rs / seed

A Rust framework for creating web apps
MIT License
3.8k stars 153 forks source link

Seed Styling System - (cont.) #413

Open rebo opened 4 years ago

rebo commented 4 years ago

Wouldn't you just prefer to simply set styles...

let style = S
    .background_color("red")  
    .margin("2px")         
    .padding("8px");       

button![style, "Hello!"]

...pretty clean!

This issue details a proposed styling system for Seed. It is currently implemented as described below in its own Seed App. I am starting a new issue and closing the old one ( #412 ) because significant changes and improvements have been made therefore it will be good to summarise the proposed api as it stands.

The goals of a Seed styling system are :

Existing solutions such as inline styles, use of existing frameworks, and .css file solutions are sub-optimum for reasons discussed in #412.

How it works

div![ 
    S.background_color("red"), 
    "hello" 
]

Property methods, such as background_color() create a Style object. On page render these are then injected into the <style> tag in the page's <head>. Styles are linked to the component that creates them by a unique class name. This enables the style to only affect that component.

Here is the css that is inserted from the above code:

.sc-EGKg8M4Zz0yOg{
    /* Defined at src/lib.rs:31:11 */
    background-color: red;
}

A comment is inserted to state where the style was originally defined.

Use of CSS

Many users will want to use CSS directly, perhaps from an online source or existing css files. This can be achieved with the css() method:

    let style = S.css(r#"
        font-family: verdana;
        font-size: 20px;
        color: white;
        text-align: center;
    "#);

    div![style, "Hello"]

Of course one can mix css with typed properties as well.

   let style = S
        .css(r#"
            font-size: 20px;
            color: white;
        "#)
        .background_color("pink");

    div![style, "Hello"]

Named styles

Whilst the unique identifier and the CSS comment can help identify a component from a style a developer may prefer to also name a style to make that identification easier. This can be done with name().

let style = S
    .background_color("red")
    .color("white")
    .name("red_button-");

button![ style , "Click Me!" ]

generates the following css:

.sc-red_button-nZQgQol056qPY{
    /* Defined at src/lib.rs:32:17 */
    background-color: red;
    color: white;
}

Convenience Properties

The ability to use functions to define properties mean that we can make use of shorter identifiers for more commonly used properties.

let style = S
    .bg_color("red")  // background color
    .m("2px")         // margin 
    .pl("8px")        // padding-left
    .duration("3s");  // animation-duration

button![ style , "Click me!" ]

Ultra-convenience properties Tailwind style

We can go further and use Tailwind style shortcuts to greatly reduce the amount of typing for common styles. Furthermore due to these being fully validated there is no possibility of typos.

button![ 
  S.p_4().m_2().inline_block().bg_red_600() , 
  "Click me!" 
]

Standard Tailwind sizes and colours have been implemented

Pseudo classes

Pseudo classes are implemented with methods on the Style struct. For instance to mark a Style as hover simply call hover() on the style.

    let base_style = S.p_4().m_2()
        .bg_red_600()
        .text_gray_200()
        .radius("4px").inline_block();

    let hover_style = S.hover()
        .bg_green_400();

    div![base_style, hover_style, "Click Me!"]

Overridable component styling

As a developer of a re-useable component you may wish to allow users to override the styles you have used.

Simply accept a Style argument and place it after the base style in the html element you want to render.

fn custom_button(custom : Style ) -> Node<Msg> {

    let style = S
        .bg_red_400()
        .text_white();

    button![style custom, "Click Me!"]
}
...
...
// called via :

custom_button(S.bg_green_200())

This means you can follow a StyledSystem approach where all components can be styled at their call site.

Media Queries

Media queries are supported. Just call media() on the style and that block will be placed in the appropriate media query:

let base_style = S.p_4().m_2()
    .bg_red_600()
    .text_gray_200()
    .radius("4px").inline_block();

let media_query = S.media("@media only screen and (max-width: 600px)")
    .bg_green_400();

div![base_style, media_query, "Click Me!"]

Variant support

Support for variants is trivial, simply just add styles to a base style and give it a name.


enum Button {
    Danger,
    CallToAction,
    Normal,
}

fn modern_button(variant: Button, text : &str, msg: Msg) -> Node<Msg> {

    let base_style = S.p_4().m_2()
        .bg_red_600().text_gray_200()
        .radius("4px").inline_block();

    //Variants are simply branches off the base style
    let danger_style = base_style.bg_red_800().text_gray_400();
    let action_style = base_style.bg_green_400().text_white();

    let style = match variant {
        Button::Danger => danger_style,
        Button::CallToAction => action_style,
        Button::Normal => base_style,
    };

    button![style, text, mouse_ev(Ev::Click, msg)]
}

The above button can therefore be rendered anywhere in your application as:

modern_button(Button::Danger, "Help!", Msg::Help)

Theme Support

Theme Support is in place based on the Theme Specification. The user provides a theme object that provides size scales and identifies common colours.

This is achieved with the Theme type:

fn pink_theme() -> Theme {
    Theme::default()
        .color("primary","#DB3080")
        .color("primary_light", "palevioletred")
        .color("secondary", "gray")
        .space_scale(["10px","20px","30px"])
        .radius("medium", "3px")
        .border_width_scale(["1px","2px","3px"])
}

To use this theme, the use_theme()function is used. Any component instantiated inside the use_theme block will have access to the theme.

  use_theme( pink_theme(), ||
    // anything inside here has access to pink_theme.
    div![
        themed_button(),
    ]
)

where inside themed_button() the theme is used as the below:

let style = S
    .p((3,"10px"))  // Padding will take the 3rd space theme value, or 10px if not present
    .m((2, "8px"))  // Margin will take the 2nd space theme value, or 8px if not present
    .bg_color(("primary","red"))  // The primary color value, or "red" if not present
    .color("white")
    .radius("2px")
    .inline_block();

button![style, "Click Me!"]

Calling any of the property methods with a ( usize, &str) tuple will use the corresponding scale value for that property or else the &str default if the theme does not exist.

Calling any of the property methods with a ( &str, &str) tuple will use the corresponding aliased value for that property or else the default if the theme does not exist.

The advantage of themes is that the entire colour scheme for a website can be adjusted in a single place (the theme object). For instance a dark or light theme might be provided.

The interesting thing about themes under the Theme Specification is that the colour themes are available to any of the properties that set color. In the above example background-color was set to the primary theme color however setting border-color or color would have worked just as well. Other properties are linked to the theme in similar ways, for instance space holds preset sizes for properties such as padding, `margin, etc.

Keyframe support

Animation via keyframes is handled with the keyframe() method. This adds a style to a keyframes block at the specified key. This keyframe block is linked to the correct css class via a unique name set on the animation-name property. For instance:

    let base_style = S.p_4().m_2()
        .text_gray_200()
        .radius("4px").inline_block();

    let key_frames_style = base_style
        .animation_duration("3s")
        .keyframe( 0 , S.bg_green_900() )
        .keyframe( 100 , S.bg_green_100() )
        .name("an_animated_div-");

    div![key_frames_style, "Click Me!"]

produces the following css:


.sc-an_animated_div_PQkmpZyG5kY6y{
    /* Defined at src/lib.rs:126:28 */
    padding: 1rem;
    margin: 0.5rem;
    color: #EDF2F7;
    border-radius: 4px;
    display: inline-block;
    animation-duration: 3s;
    animation-name: anim-an_animated_div-PQkmpZyG5kY6y;
}

@keyframes anim-an_animated_div-PQkmpZyG5kY6y{ 
0% {
    /* Defined at src/lib.rs:128:24 */
    background-color: #22543D;
}
100% {
    /* Defined at src/lib.rs:129:26 */
    background-color: #F0FFF4;
}  
}

Summary

As a whole the described style system achieves most of its goals. It can be used in a variety of ways familiar to users of a variety of CSS frameworks.

If someone can make any suggestions of niceties or even core functionality that you would like to include let me know.

Assuming users find the above code reasonable, concise and clean I will tidy up my code a little (i.e. it is all over the place right now) and release a sample app that can be played around with.

rebo commented 4 years ago

Here is an example app that demonstrates all of the above features:

https://github.com/rebo/seed_styling_app

Please note that you need to be on nightly (at least since 4th April 2020) due to new compiler features added recently.

rebo commented 4 years ago

Here is an update on the api for fully typed CSS (i.e. properties and values).

lib.rs

This file demonstrates creating a theme definition, including the named scales that are used in an app. This is done via simple enums:

#[derive(Hash,PartialEq, Eq, Clone)]
enum Color {
    Primary,
    DarkPrimary,
    Secondary,
    ...
    ...

Once a theme is defined, specific values can create a theme instance:

  AnyTheme::new()
    .set(Color::Primary, CssColor::Hsl(200.0,70.0,80.0))
    .set(Color::Secondary, hsl(300,60,50)) // or use the hsl shortcut
    .set(Color::Highlight, hsl(310,70,85))
    .set(Color::DarkPrimary, hsl(200,70,35))
    ...
    ...

As you can see above fully typed CssColor can be used, alternatively a shortcut hsl() can be used which creates this enum variant.

The typing is fairly extensive, so its often a good idea to use the helper functions:

.set(Size::Small, CssSize::Length(ExactLength{value:ordered_float::NotNan::new(4.0).unwrap(), unit:seed_hooks::style::measures::Unit::Px}))  
    vs
.set(Size::Medium, px(4).into()) // or use the px shortcut

Using a theme is as simple as passing the theme variant to the relevant css method:

   button![
        S.background_color(Color::Primary).color(Color::Secondary)

Of course &str arguments can be used here as well. I.e. S.color("#FFF") is available if you need it.

Full css method names can be a bit annoying, so shorter helper ones are available:

 button![
        S.bg_color(Color::Primary).color(Color::Secondary)
            .radius(px(3))
            .px(Space::Large)
            .py(Space::Medium)

Theme scales can also be used although that is not demonstrated here.

If you have any suggestions for the api surface please give any suggestions.

rebo commented 4 years ago

Some notes based on some Discord discussions.

Currently the system is flexible in that all the below are possible.

Color properties: (i.e. background-color, border-left-color etc).

S.color(MyColors::Primary)  // typical themed 
S.color(Primary) // works if you `use MyColors::*`
S.color(CssColor::Hsl(40.,20.,80.))  // Typed with Hsl Variant
S.color(CssColor::Rgba(40.,20.,80.,0.3))  // Typed with Rgba Variant
S.color(CssColor::Hex(0xfff)  // Typed with Hex Variant
S.color(hsl(40,20,80)) // Typed with hsl helper
S.color(rgba(40,20,30,0.2)) // Typed with rbga helper
S.color(rgb(40,20,30)) // Typed with rbg helper
S.color("#34542") // &str if people still want to use direct Strings.

For colors all the above seem reasonable.

For space properties I..e margin padding-left etc. We have:

S.padding(px(3))  // typed with px helper
S.padding(cm(2)) // typed with cm helper
S.padding(rem(1.4)) // typed with rem helper

Variants with (many) Arguments

// non-helper arguments can get verbose though
S.padding(CssPadding::Auto)  // not too bad..
S.padding(CssPadding::Length(ExactLength{ value: 2.0, unit:Units::Px))) // now getting a bit annoying

Something like specifying a typed border is particularly annoying due to needing to specify width style and color. Technically width and color are optional however currently the enum needs all arguments specified. Therefore this is currently needed to fully specify a Css Border.

S.border(CssBorder::Border(BorderWidth::Length(ExactLength{value: "2.0", unit: Units::Px}),BorderStyle::Solid, BorderColor::Hsl(40.,30.,20.)))

no-one really has time for that. Especially just to produce border: 2px solid hsl(40,30,20);

You can't even use helper functions in the fully specified version because types have to match exactly. I.e. this wont compile:

S.border(CssBorder::Border(px(2),BorderStyle::Solid, hsl(40,30,20)) // still long and doesn't work

therefore we have a number of options.

0) Do nothing 1) many more global namespace helper functions beyond length (px,cm )and colors( rgb, hsl) This becomes difficult to remember and discover. 2) additional methods on S. i.e.

S.border_width_style_color(
    Some(px(3)),
    CssBorderStyle::None, 
    Some(rgb(40,60,200))
) // still verbose and annoying for optional arguments.

These are discoverable through autocomplete on S. but (many) hundreds of methods could slowdown autocomplete. 3) additional helper methods on each Css Type. i..e

S.border(CssBorder::solid_with_color_width(px(3),rgb(40,60,200)) // still ugly

4) accept a fn argument to act as a builder for the more annoying variants. I.e.

S.border(|v| v.solid().color(rbg(40,50,200)).px(2) )
S.border(|v| v.color(rbg(40,50,200)).px(2) ) // won't compile because a `style` is not present

Whist this is more terse, Im not feeling it 100% for any of these options really.

Maybe the solution is simply not use a composite property like CssBorder and instead use the constituent properties directly.

S.border_width(px(2))
   .border_color(rgba(30,40,50))
   .border_style(CssBorderStyle::None)

Not perfect but easier to scan.

In fact short helper methods for no argument variants. Make this even nicer:

S.border_width(px(2))
   .border_color(MyColor::Primary))
   .border_style_none()

So I think that is where it will go.

Theming/Tailwind considerations

Currently Tailwind style shortcuts are included and work. i..e

S.text_red_300() // which translates to S.color(CssColor::hex(0xfeb2b2))
S.pl_2() // which translates to S.padding_left(rem(2))

Whilst these are fine as such, they massively increase the api surface which is already pretty big due to the various ways in which the styling system can be interacted with. Therefore probably going to move towards allowing shorter names but not using all the Tw Variants. Instead suggest scales either from a Theme or predefined. i..e

S.color(Colors::Red::n2)  // Number 2 red from the theme.
S.pl(px(2))  // allow `pl()` as short version of padding-left.
rebo commented 4 years ago

Just an update prior to uploading an alpha of this.

Combinators are now implemented. i.e. S.is_child_of("p").font_weight_bold()

will produce:

p > [component_id] {
    font-weight: bold;
}

and S.adjacent_follows("li").font_style_italic() will produce

li + [component_id] {
    font-style: italic;
}

Style properties accept slices of css values and are linked to the current themes' media query breakpoints. i.e.

S.padding( &[px(4), px(8), px(10)])

Will use 4px for the 1st breakpoint 8px for the next and 10px for the third.

This is a quick way of making styles responsive

Lastly Theme scale values can be referred to directly.

I.e for theme scale Theme::new.border_widths_scale([2px, 4px, 6px, 10px])

S.border_width(2)

will output

[component_id]{
    border-width: 6px;
}

For more information on Theme scales see the theme specification : https://styled-system.com/theme-specification/ .

rebo commented 4 years ago

Another update, a Layout System is pretty much complete. With this you can create your layout declaratively. Then wire up specific views to typed areas once you have written them.

Define areas that might appear in a layout :

enum Area {
    Header,
    MainContent,
    Nav,
    SideBar,
    Footer,
    None,
}

Define the layout as a typed array:

use Area::*;
let mut layout = SeedLayout::new(&[
    &[Header, Header, Header],
    &[SideBar, MainContent, MainContent],
    &[Footer, Footer, Footer],
]);

// and render
layout.render()

Produces this (note stub areas auto created based on enum variant):

image

If you start assigning Node<Msg> views to areas:

layout.set(Header, red_background_view);

Then each area is filled with the respective view.

image

The layout system is fully responsive. Define a layout for small screens:

let mut sm_layout = SeedLayout::new(&[
    &[Header], 
    &[Nav], 
    &[MainContent],
    &[Footer]
]);

and compose with the larger layout keyed by theme breakpoints:

let mut comp = Composition::default();
comp.add(&[Breakpoint::LargeScreen], layout);
comp.add(&[Breakpoint::SmallScreen], sm_layout);
comp.render

and media queries & conditional rendering will automatically be setup to render this at lower screen sizes:

image

Nested layouts work fine. just comp.render() or layout.render() in a view function.

Media Query functionality does not stop there.

Conditionally render anything based on media query and theme breakpoints:

only(
    Breakpoint::LargeScreen, || 
    div![
        S.w(px(80)).bg_color(Color::Primary),
        "This div is rendered only on large Screens"
    ]
)

Alternatively you can render at that breakpoint and below (only_and_below) or above (only_and_above) or not at that breakpoint (except).

Furthermore Css can be scoped to specific breakpoints by using the same methods but on a style object.

div![
    S.only_and_below(Breakpoint::SmallScreen).color(rgb(255,0,0)),
    This text is red on small screens
]

alternatively set properties for your theme breakpoint scale at once by passing an array to the repective property:

// in the theme
.breakpoint_scale([480, 800, 960]);

// set different shades and font sizes at the above breakpoints
S.background_color(&[
    hsl(10, 80, 20),  // from 0 to 479
    hsl(10, 80, 50), // from 480 to 799
    hsl(10, 80, 80), // from 800 to 959
    hsl(10, 80, 90), // 960 and up etc...
])
.font_size(&[px(12) , px(14), px(18)])

Ok that is all for now! hope you like.

flosse commented 4 years ago

Ok that is all for now! hope you like.

awesome!

thedodd commented 4 years ago

Outstanding!

Ben-PH commented 4 years ago

This is next-level!

asvln commented 4 years ago

I spent some brain-time building this atomic SASS library and I think it might provide some different approaches to glance at as the element API becomes stable. In particular, it was the best way I could imagine implementing flexbox and worked really well in practice.