Jarzka / stylefy

Clojure(Script) library for styling user interface components with ease.
MIT License
317 stars 10 forks source link

Support styling based on ancestor class (.parent &) #59

Closed shanberg closed 3 years ago

shanberg commented 3 years ago

A common issue in component styling is the ability to modify the style of the current object based on the state of one of its ancestors.

.my-component { color: blue }
.os-macos .my-component { color: green }
.os-windows .my-component { color: orange }

The & selector seems to only implement part of the traditional SCSS/Less featureset. I can use it to create selectors like &:hover -> .my-component:hover, but not :hover > & -> :hover > .mycomponent.

I haven't been able to replicate this kind of styling workflow using stylefy.

Jarzka commented 3 years ago

Have you tried using stylefy's manual mode to write this kind of selector manually? Manual mode can be used to write custom CSS using Garden syntax. The parent selector is also supported in Garden:

As in Less/Sass, Garden also supports selectors prefixed with the & character allowing you to reference a parent selector:

Another possible option is to have os-macos and os-windows as Clojure data and pass them as parameters for styling functions.

shanberg commented 3 years ago

@Jarzka

Here's what I get with manual mode and when using garden selector:

(def my-component-style
  {:color "red"
   ::stylefy/manual [[(selectors/& :.test) {:color "blue"}]
                     [(:.test selectors/&) {:color "blue"}]
                     ["&.test" {:color "blue"}]
                     [".test &" {:color "blue"}]]})

which evaluates to:

._stylefy_2087609553 {
    color: red;
}
._stylefy_2087609553.test {
    color: blue;
}
._stylefy_2087609553.test {
    color: blue;
}
._stylefy_2087609553 .test & {
    color: blue;
}

and what I need instead is

.test ._stylefy_2087609553 {
    color: blue;
}

I'm currently working around this by adding classes directly to the component, but there are potentially very many cases where I'd like to use this format, and subscribing to the same data in many parts of our application is pretty hairy.

Jarzka commented 3 years ago

I see, stylefy scopes manually written selectors inside the current component, so there is really no (easy) way to select anything outside of it. This is a design choice, and for the most part I see it as a benefit, because it helps keeping styles scoped locally to the current component.

However, perhaps it would be handy to override the default scoping in these kind of situations - maybe there should be an option to define the current scope of the style. I have to think about this.

Jarzka commented 3 years ago

I have been thinking of a possible solution for this.

Media queries are used to create a new scope for a style, i.e. we define a style which is activated when the defined media query is active. We could use the same idea with custom scopes; we define a style which is activated when the defined scope (CSS selector) is active.

For example: (not implemented anywhere, just an idea):

(def style-map
  {:color :grey
   ::stylefy/scope [[[:.os-macos] {:color "blue"}]]
                    [[:.os-windows] {:color "purple"}]]]})

::stylefy/scope should be a vector of scopes. A scope is a vector in which:

So (use-style style-map) would output the following CSS:

._stylefy_xxxxxxx {
    color: grey;
}

.os-macos ._stylefy_xxxxxxx {
    color: blue;
}

.os-windows ._stylefy_xxxxxxx {
    color: purple;
}

I think this could work in a simple case like this, not sure about more complex ones. Also, I'm not sure if we could use scopes and media queries together without creating ambiguous style maps. ::stylefy/scope feels like a more advanced way of using ::stylefy/media, so these probably would not work together (you could and should define your media queries manually in scope selector).

EDIT: I think scopes and media queries could also be used together:

Input:

(def style-map
  {:color :grey
   ::stylefy/media {{:max-with "20rem"} {:color "yellow"
                                         ::stylefy/scope [[[:.os-macos] {:color "black"}]]
                                                          [[:.os-windows] {:color "white"}]]]}}
   ::stylefy/scope [[[:.os-macos] {:color "blue"}]]
                    [[:.os-windows] {:color "purple"}]]]})

Output:

._stylefy_xxxxxxx {
    color: grey;
}

.os-macos ._stylefy_xxxxxxx {
    color: blue;
}

.os-windows ._stylefy_xxxxxxx {
    color: purple;
}

@media (max-width: 20rem) {
    ._stylefy_xxxxxxx {
        color: yellow;
    }

    .os-macos ._stylefy_xxxxxxx {
        color: black;
    }

    .os-windows ._stylefy_xxxxxxx {
        color: white;
    }
}

What do you think about this?

shanberg commented 3 years ago

Maybe we should only accept simple selectors, like "inside class A which is inside element B"

This makes me a little nervous. It sounds like the following nesting would be impossible:

(def style-map
  {:color "red"
   ::stylefy/scope [[[:.os-macos] {:color "blue"}
                     [:.close-button {:color "yellow"}]]
                    [[:.os-windows] {:color "yellow"}
                     [:.close-button {:color "blue"}]]]})

And perhaps I'd have to create separate maps like (def close-button-style) with the same set of scopes to handle this case.

Would manual-style selectors be available in scopes, e.g.?

(def style-map
  {:color "red"
   ::stylefy/scope [[".os-macos > .app-toolbar:hover"] {:color "green"}]})

...

Ultimately, virtually any limit on selector complexity will become a future pain point, but a limited solution would get me past my current roadblock, and I'll be grateful for that.

Jarzka commented 3 years ago

Ultimately, virtually any limit on selector complexity will become a future pain point

This is true. Limiting selector complexity would solve simple cases, but there will be cases in which simple selectors are simply not enough.

Would manual-style selectors be available in scopes, e.g.?

This could be a good way to begin with. I have also been thinking of enabling support for manual-style selectors when using stylefy's manual mode, since some folks may find it easier / more advanced than writing Garden-selectors.

Since Garden uses vector-based selectors, I think we can simplify the scope syntax a bit when using string selectors. The first element in scope vector could be the string selector itself:

(def style-map
  {:color "red"
   ::stylefy/scope [[".os-macos > .app-toolbar:hover" {:color "green"}]
                    [".os-windows > .app-toolbar:hover" {:color "blue"}]]})

Now it should be easy to append the generated class name at the end of the selector to create the final selector: .os-macos > .app-toolbar:hover ._stylefy_xxxxxxx.

Later, if we find a reliable way to support Garden selectors, we have to check the syntax of the scope vector. If the first element is a string and the second element is a map, we are using manual style, otherwise, it is a Garden selector.

EDIT: I noticed that this is also valid Garden syntax:

(garden.core/css [".os-macos > .app-toolbar:hover" {:color "green"}])
=> ".os-macos > .app-toolbar:hover {\n  color: green;\n}"

So... I think we can assume that if the item in scope is a vector, it is a valid Garden selector, but the selector itself should be written manually (the first element is a string and the second element is a map), so that we can easily append the generated class name at the end of it. But I'm not sure if we can ever support "real" Garden syntax if we have this limitation, and there is also a risk that we are going to run into some syntax conflict at some point.

Another option is to make the following assumption:

If the item in scope is a vector, we assume it is valid Garden syntax and can be written the way you like. However, this is not going to be supported yet, i.e. this would throw an error in the first implementation. If the item in scope is a map, it should explicitly tell what syntax you are using:

(def style-map
  {:color "red"
   ::stylefy/scope [{:css [".os-macos > .app-toolbar:hover" {:color "green"}]}
                    ; Explicit garden syntax, not going to be supported yet
                    {:garden [:.os-windows [:.app-toolbar [:&:hover {:color "blue"}]]]}
                    ; Vector items are assumed to be Garden syntax (also not supported yet)
                    [:.os-windows [:.app-toolbar [:&:hover {:color "blue"}]]]]})

This would sound like a future-proof solution to me.

Jarzka commented 3 years ago

About Garden syntax support. I think it should be possible to achieve with the following algorithm:

So this:

{:color "red"
 ::stylefy/scope [[:.os-macos {:color "blue"}]
                  [:.os-windows {:color "yellow"}
                  [:.what [:.ever {:color "purple"}]]]]}

Would end up looking like this after the conversion:

{:color "red"
 ::stylefy/scope [[:.os-macos [:._stylefy_xxxxxx {:color "blue"}]]
                  [:.os-windows [:._stylefy_xxxxxx {:color "yellow"}]
                  [:.what [:.ever [:._stylefy_xxxxxx {:color "purple"}]]]]]}

Which would end up like this in CSS:

.os-macos ._stylefy_xxxxxx {
  color: blue;
}

.os-windows ._stylefy_xxxxxx {
  color: yellow;
 }

.what .ever ._stylefy_xxxxxx {
  color: purple;
}

I also tested a few more complex selectors and they ended up looking ok: stylefy's autogenerated class name is always the last part of the selector. So, maybe we don't need the manual way after all. We could support Garden syntax, and you can always use string selectors as part of the Garden selector if you prefer.

shanberg commented 3 years ago

This looks like an improvement to me.

It looks like stylefy/scope is always prepending a set of selectors to the autogenerated classname. Is it possible to also append selectors, e.g. to return this: .os-windows ._stylefy_xxxxx .child-of_stylefy_xxxxx { color: blue }?

What I'm accustomed to is being able to use & in any part of a nested selector to represent the parent selector, so I'm most excited by whatever gets me closest to that behavior.

Jarzka commented 3 years ago

I do not think this would be possible with the currently designed implementation; the autogenerated class name of the current style would always be the last element in the scoped CSS selector. So ::stylefy/scope would always mean "this style in this context". Child elements could be scoped separately. Imo it would make more sense to do it that way. Or... you could possibly use ::stylefy/manual to style them in the parent element's scoped style map.

{:color "red"
 ::stylefy/scope [[:.os-macos {:color "blue"
                               ::stylefy/manual [[:.child {:color "green"}]]}]]]}
._stylefy_xxxxxx {
  color: red;
}

.os-macos ._stylefy_xxxxxx {
  color: blue;
}

.os-macos ._stylefy_xxxxxx .child {
  color: green;
 }

Also, I'm not sure if it is a good idea to ever use autogenerated class names in CSS selectors since those names can easily change. It would be a better idea to attach additional "human readable" class name to your elements and refer to them instead.

Jarzka commented 3 years ago

I had a little bit of time today to create a minimal implementation of this feature. It's available on the feature/scope branch. It works as I explained in my previous post, except that the scoped style map does not yet support stylefy's special keywords (this turned out to be a bit trickier than I expected because Garden conversion needs the full selector and style map, I cannot convert only the selector or the style map separately).

Jarzka commented 3 years ago

Vendor prefixes, ::stylefy/mode and ::stylefy/manual can now be used inside scoped style map:

(def scoped-box
  {:font-weight :bold
   ::stylefy/scope [[:.scoped-box {:color "red"
                                   ::stylefy/mode {:hover {:color "yellow"}}
                                   ::stylefy/manual [[:.green-text-in-scoped-box {:color "green"}]]}]]})

Output:

._stylefy_974852753 {
  font-weight: bold;
}

.scoped-box ._stylefy_974852753 {
  color: red;
}

.scoped-box ._stylefy_974852753:hover {
  color: yellow;
}

.scoped-box ._stylefy_974852753 .green-text-in-scoped-box {
  color: green;
}

This is still pretty much experimental work and should not be used in production. Still, it would be nice to get some feedback. :)

shanberg commented 3 years ago

I'm having difficulty building this locally to give it a rigorous test, but I'm happy with the syntax you've described and the examples you've demonstrated. This looks like it entirely covers my use cases.

Thanks so much for pursuing this.

Jarzka commented 3 years ago

I think you should be able to test it by cloning the repo, switching to feature/scope branch and running lein install. After that, require [stylefy "3.1.0-SNAPSHOT"].

Anyway, I'm probably going to release a public beta at some point - when the work has progressed a bit further.

shanberg commented 3 years ago

I've just tested it and it’s working great so far. It’s a relief to be able to set a few classes at the app root and use them much farther down.

Jarzka commented 3 years ago

Scoping now supports media queries. You can write them either inside the scoping query...

{:font-weight :bold
 ::stylefy/scope [[:.scoped-box {:color "red"
                                 ::stylefy/mode {:hover {:color "yellow"}}
                                 ::stylefy/manual [[:.special-text-in-scoped-box {:color "green"}]
                                                   (at-media {:max-width "500px"} [:.special-text-in-scoped-box {:color "purple"}])]}]]}

Output

._stylefy_-281407580 {
    font-weight: bold;
}

.scoped-box ._stylefy_-281407580 {
    color: red;
}

.scoped-box ._stylefy_-281407580:hover {
    color: yellow;
}

.scoped-box ._stylefy_-281407580 .special-text-in-scoped-box {
    color: green;
}

@media (max-width: 500px) {
    .scoped-box ._stylefy_-281407580 .special-text-in-scoped-box {
        color: purple;
    }
}

...or have a ::stylefy/media style definition with scoping rules. The CSS output is a bit different, but the end result is virtually the same:

{:font-weight :bold
 ::stylefy/scope [[:.scoped-box {:color "red"
                                 ::stylefy/mode {:hover {:color "yellow"}}
                                 ::stylefy/manual [[:.special-text-in-scoped-box {:color "green"}]]}]]
 ::stylefy/media {{:max-width "500px"}
                  {::stylefy/scope [[:.scoped-box {::stylefy/manual [[:.special-text-in-scoped-box {:color "purple"}]]}]]}}}

Outputs:

._stylefy_-646023592 {
    font-weight: bold;
}

.scoped-box ._stylefy_-646023592 {
    color: red;
}

.scoped-box ._stylefy_-646023592:hover {
    color: yellow;
}

.scoped-box ._stylefy_-646023592 .special-text-in-scoped-box {
    color: green;
}

@media (max-width: 500px) {
    ._stylefy_-646023592 {}
}

@media (max-width: 500px) {
    .scoped-box ._stylefy_-646023592 {}
    .scoped-box ._stylefy_-646023592 .special-text-in-scoped-box {
        color: purple;
    }
}
Jarzka commented 3 years ago

This feature is looking good so I opened a PR for it: https://github.com/Jarzka/stylefy/pull/60/files

I'm planning making this official soon if no issues are found.

Jarzka commented 3 years ago

This feature is now live in 3.1.0

Jarzka commented 3 years ago

I updated the scoping examples in the README file. I added an example in which media queries are used directly inside ::stylefy/scope.

Since this feature is now done, I'll close this issue. If there is something related to this, let's discuss that in a new thread.