wix / stylable

Stylable - CSS for components
https://stylable.io
MIT License
1.27k stars 62 forks source link

proposal: unify selector definition API #2848

Open idoros opened 1 year ago

idoros commented 1 year ago

WIP

This proposal tries to unify the way selector API is defined in stylable. It's replacing previous st-part proposal for a broader solution aims to make the syntax simpler while adding capabilities that have been long requested and required for future experience we talked about.

Goals

Base Proposal

Single at-rule directive to enrich CSS definitions.

Define a Component

/* mark the .btn class as a component */
@st .btn;
/* or more verbosely */
@st-comp .btn;

Define Pseudo States

Nested pseudo-states are defined as nested @st following a pseudo-class syntax (colon+name). To set a non boolean state the name can be followed with parentheses with type definition similar to how states are defined today.

@st .btn {
    /* boolean state */
    @st :toggled;
    /* enum state */
    @st :size(enum(small, medium, large));
}

Equivalent today

/* btn.st.css */
.root {
    -st-states: 
        toggled, 
        size(enum(small, medium, large));
}

usage

/* selector api usage */
.btn:toggled {}
.btn:size(small) {}

Define Pseudo Elements

Nested pseudo-elements are defined as nested @st following a pseudo-element syntax (double colon+name) with fat-arrow mapping them to a selector a relative selector list.

@st .gallery {
    @st ::navBtn => .btn;
    @st ::multi => nav > :is(.a, .b);
    @st ::compound => &.x;
    @st ::compoundMulti => &:is(.x, .y);
}

Equivalent today

/* gallery.st.css */
.root {}
@custom-selector :--navBtn .btn;
@custom-selector :--multi nav > .a, .b;
@custom-selector :--compound .root.x;
@custom-selector :--compoundMulti .root.x, .root.y;

Usage

.gallery::navBtn {}
.gallery::multi {}
.gallery::compound {}
.gallery::compoundMulti {}

/* transforms to*/
.ns__gallery .ns__btn {}
.ns__gallery :is(nav > .ns__a, .ns__b) {}
.ns__gallery.ns__x {}
.ns__gallery:is(.ns__x, .ns__y) {}

Notice that mapping selectors can either be compounding to the component or relative to it, but not mixed. This is a limitation of selectors. To allow combination of such cases, we would need to transform into multiple selectors and would loose the unified specificity that is achieved by grouping the selectors in :is(). Multiple selector mapping is not allowed, to achieve this, the multiple selectors can be written into :is()/:where()/:has()  

Map to Class (syntactic sugar)

In most cases components need to map parts to simple classes. For this, this proposal offers to allow definition of pseudo-element with a nested @st following a class name.

@st .gallery {
    @st .navBtn;
    /* equivalent to */
    @st ::navBtn => .navBtn;
}

There is some negative feedback about this syntax - we might want to find an alternative

Equivalent today

/* gallery.st.css */
.root {}
.navBtn {}

Deep Structure

Nested definitions or parts and states can help define deep API. This is a new capability that the current syntax doesn't allow without splitting into multiple stylesheets.

@st .gallery {
    @st .navBtn {
        @st :position(enum(first, middle, last));
        @st .label;
    }
}

/* selector api usage */
.gallery:navBtn::label {}
.gallery:navBtn::position(first) {}

Inheritance

In order to reuse definitions, a component or an inner part can extend a known definition.

@st .hasX {
    @st .x;
}

/* extend .comp with .hasX */
@st .comp:is(.hasX) {
    /* extend .part with .hasX */
    @st .part:is(.hasX);
}

Equivalent today

/* comp.st.css */
.root {
    -st-extends: hasX;
}
.part {
    -st-extends: hasX;
}

Usage

/* selector api usage */
.comp::x {}
.comp::part::x {}

Inheritance hide

Inherited states and parts can be blocked from usage with @st-hide

@st .base {
    @st .a;
    @st .b;
}

/* extends .comp with .base */
@st .comp:is(.base) {
    /* block ::a from being used as a selector*/
    @st-hide ::a;
}

Inheritance override

Inherited states and parts can be overridden by re-defining them. While override like this is possible with the current syntax, we probably want to add explicit "override" syntax to show intent and prevent unintended API overlap.

@st .base {
    @st .a;
    @st .b;
}

/* extends .comp with .base */
@st .comp:is(.base) {
    /* explicit override */
    @st-override ::a => .x;

    /* extends ::part with .base */
    @st .part:is(.base) {
        /* explicit override */
        @st-override ::b => .y;
    }
}

Equivalent today

/* comp.st.css */

/* definition with parts must be defined in a separate stylesheet */
@st-import Base from './base.st.css';

.root {
    -st-extends: Base;
}
/* override part and map to selector */
@custom-selector :--a .x;

.part {
    -st-extends: Base;
    /* override of part API is not possible in the same stylesheet 
    In order to get a similar API, another stylesheet is required */
}

Usage

.comp::a {}
.comp::b {}
.comp::part::a {}
.comp::part::b {}

/* transforms to*/
.ns__comp .ns__x {}
.ns__comp .ns__b {}
.ns__comp .ns__part .ns__a {}
.ns__comp .ns__part .ns__y {}

Global

To escape out of namespacing, for targeting non-stylable CSS, mapping to global namespace can be used.

/* target external component */
@st .comp => :global(.lib__comp) {

    /* map state to template like today */
    @st :state('[data-$0]', enum(checked, unchecked, indeterminate));

    /* POTENTIAL ALTERNATIVE */
    @st :state(enum(checked, unchecked, indeterminate)) => "[data-$0]";

    /* define part that is transformed to global class */ 
    @st ::part => :global(.lib__part);
}

Equivalent today

/* lib-comp.st.css */
.root {
    -st-global: '.lib__comp';
    -st-states: state('[data-$0]', enum(checked, unchecked, indeterminate));
}
@custom-selector :--part :global(.lib__part);

Usage

.comp:state(checked)::part {}

/* transforms to*/
.lib__comp[data=checked] .lib__part {}

Base styling

While Stylable encourage separation of component interface and style, some cases might want to include styling because they are part of the base look/structure. So styles nested within @st definitions are allowed and preserved in transpilation.

@st .comp {
    /* style comp */
    color: red;

    @st .part {
        /* style part*/
        color: green;
    }
    /* style nested part */
    &:hover {
        color: blue;
    }
}

/* transforms to*/
.ns__comp {
    color: red;
    .ns__part {
        color: green;
    }
    &:hover {
        color: blue;
    }
}

Ambient root

This change allows us to define multiple components in a single stylesheet, and makes the default .root class unnecessary.

In addition it opens up the possibility for components that have different parts that aren't nested under a single root, like a tooltip with the anchor part and the popup part.

/* tooltip.st.css*/
@st .anchor {}
@st .popup {}
@st-import Tooltip from './tooltip.st.css';

Tooltip::anchor {}
Tooltip::popup {}

/* transforms to*/
.tooltip__anchor {}
.tooltip__popup {}

Open questions / considerations

Base styling specificity

Any styling set within the @st definition is considered as base style, and should be easily overridden by customization, however the deep structure causes the styles to gain specificity that can be hard to override.

There are 2 possible options that we are currently considering:

  1. wrap transformed selectors with :where to minimize base styling specificity
  2. wrap components definition in @layer to isolate it from any higher level component

A layer can always be added manually by a user (and is also less supported atm), and the where can be optional with some added configuration.

Base styling inheritance strategy

When setting nested styles within the @st definition, does pseudo selectors refer to the defined component or the extended definition?

Option 1 - refer to the extended definition:

/* assuming .base also has "::part" */
@st .comp:is(.base) {
    &::part {
        /* style base part */
        color: red;
    }
    @st-override .part {
        /* style comp part */
        color: blue;
    }
}

Option 2 - nesting parts refer to extended until override (order matters):

/* assuming .base also has "::part" */
@st .comp:is(.base) {
    &::part {
        /* style base part */
        color: red;
    }
    @st-override .part {
        /* style comp part */
        color: blue;
    }
    &::part {
        /* style comp! part */
        color: green;
    }
}

@st in @media?

If base styling is allowed, is there a way to define @st parts and states within media/conditional rules?

@st .comp {
    @st ::part;
    @media (height > 600px) {
        /* is this allowed? does it conflict with another definition? */
        @st ::part;
    }
}

Export classes with "." prefix

We wanted to change the way @st-import works to have classes prefixed with . for a while and it might be a good chance to change the mode when opting-in to use this new mode. This will remove the ambiguity with imported elements and collisions with other symbols that cannot start with a dot.

@st-import [.class] from '.some.st.css';
/* instead of */
@st-import [class] from '.some.st.css';

This change can be made in 2 places:

  1. in the module itself that opts-in to use the new @st at-rule
  2. in modules import statements from the opt-in stylesheet

The first is more self contained and the second one will probably prevent some stylesheets from migrating to the new syntax, so their consumers won't have to change anything.

idoros commented 1 year ago

Work plan

Strict class dot prefix

V0 - #2386

V1

Base styling

Ambient root

Pseudo-Element inheritance

syntax: @st ::part(Base) => mapped-selector

Syntactical Sugar pseudo-element to class


V2

Private statements

Explicit override statements

Multiple extends


V3

Set selector context

Top level @st for non-class

More semantic definitions