Skip to content

proposal: unify selector definition API #2848

@idoros

Description

@idoros

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

  • Allow deep pseudo-element definition under the same stylesheet
  • Allow multiple components in a single stylesheet
  • A way forward to define native CSS/DOM APIs (e.g. custom elements and attributes)
  • Unified syntax

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.

Metadata

Metadata

Labels

coreProcessing and transforming logicdiscussionOngoing conversationfeatureNew syntax feature or behavior

Type

No type

Projects

Status

No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions