Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

proposal: unify selector definition API #2848

Open
idoros opened this issue Apr 4, 2023 · 1 comment
Open

proposal: unify selector definition API #2848

idoros opened this issue Apr 4, 2023 · 1 comment
Assignees
Labels
core Processing and transforming logic discussion Ongoing conversation feature New syntax feature or behavior

Comments

@idoros
Copy link
Collaborator

idoros commented Apr 4, 2023

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.

@idoros idoros added feature New syntax feature or behavior discussion Ongoing conversation core Processing and transforming logic labels Apr 4, 2023
@idoros idoros self-assigned this Apr 4, 2023
@idoros
Copy link
Collaborator Author

idoros commented Apr 17, 2023

Work plan

Strict class dot prefix

  • Any point in the new syntax that takes a class reference will not fallback to the class name with no dot prefix, making reference to both class-selector and type-selector ambiguous.

V0 - #2386

V1

Base styling

  • allow declarations and nested rules within @st body
  • transform away any build-time statement syntax keeping the inline declarations
  • disallow nested @st within @media and other nested at-rules
  • lsp: check for native declarations and nested rules

Ambient root

  • default import from a stylesheet with @st is handled:
    • import the root class if exist
    • import an new ambient-root symbol that cannot be used alone in a selector, but can be used as a record-like-selector to access the top level @st .class selectors (transforms away)

Pseudo-Element inheritance

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

  • register pseudo-element extended reference to part-symbol[-st-extends]
  • error on extends issues
  • transform inherited selector (check case for selector intersection between extends and inferred mapped selector)
  • lsp:
    • suggest extends syntax (&0) completion
    • suggest available references in extends parenthesis

Syntactical Sugar pseudo-element to class

  • decide on the syntax and implement...

V2

Private statements

  • filter out from named imports any class that is not defined with @st
  • use @st-hide ::part to override inherited API and block it
    • transformation error
    • lsp: should not suggest part
  • use top level @st-hide .class to prevent it from being named export (maybe @st-private?)
  • figure out other symbols private/public modifiers (keyframes, layers, build-vars, custom-properties)

Explicit override statements

  • use @st-override to explicitly override an extended selector API
  • add reference override API in the normal @st override error

Multiple extends

  • allow setting multiple references in extend

V3

Set selector context

  • new selector API to get an internal selector:st-ctx(A::B)::C => .c

Top level @st for non-class

  • @st type-selector - support native and custom elements definition
  • @st Interface - support interface definition

More semantic definitions

  • @st-theme
  • @st-variant

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
core Processing and transforming logic discussion Ongoing conversation feature New syntax feature or behavior
Projects
Status: No status
Development

No branches or pull requests

2 participants