sassscss-mixins

SCSS mixin with sibling selector that can be nested


I wrote two Sass mixins for adding a top margin to elements that are not the first visible child of their parent:

@mixin not-first-child {
  :not(.visually-hidden, .hidden) ~ & {
    @content;
  }
}

@mixin margin-top($spacing: $paragraph-spacing) {
  --margin-top-spacing: #{$spacing};
  margin-bottom: 0;

  @include not-first-child {
    margin-top: var(--margin-top-spacing);
  }
}

So this SCSS:

.foo {
  @include margin-top(1rem);
}

will be transformed to this CSS:

:not(.visually-hidden, .hidden) ~ .foo {
  --margin-top-spacing: 1rem;
  margin-bottom: 0;
  margin-top: var(--margin-top-spacing);
}

But this approach cannot be used in nested selectors.

.foo {
  // some styles for .foo

  .bar {
    @include margin-top(1rem);
  }
}

I would expect this to convert to

.foo :not(.visually-hidden, .hidden) ~ .bar {
  --margin-top-spacing: 1rem;
  margin-bottom: 0;
  margin-top: var(--margin-top-spacing);
}

but instead I get this:

:not(.visually-hidden, .hidden) ~ .foo .bar {
  --margin-top-spacing: 1rem;
  margin-bottom: 0;
  margin-top: var(--margin-top-spacing);
}

which is not what I want to achieve.

I read this article about advanced nesting and tried to rewrite the first mixin:

@mixin not-first-child {
  @at-root #{selector.nest(':not(.visually-hidden, .hidden) ~', &)} {
    @content;
  }
}

but this didn't make a difference. I also tried various other selector functions, but without success.


Solution

  • Thanks a lot @Martin for your detailed answer! All three approaches are very impressive, and I'm sure most people having a similar issue will be happy with one of the solutions.

    However, none of them can be applied in a backwards-compatible way to our codebase, so I searched for another solution where I can keep the mixin's signature as-is. One of the links you mentioned contained the trick: selectors are lists! Therefore, we can manipulate them with Sass's list functions.

    That way, we can split up the selector and append the sibling selector right before the last item in the list:

    @use 'sass:list';
    
    @mixin not-first-child {
      $not-hidden: ':not(.visually-hidden, .hidden)';
    
      @each $selector in & { // & can be a list of multiple selectors, separated by comma
        $length: list.length($selector);
        $selector-prefix: ();
        $i: 1;
    
        @while $i < $length {
          $selector-prefix: list.append($selector-prefix, list.nth($selector, $i));
          $i: $i + 1;
        }
    
        $selector-prefix: list.join($selector-prefix, $not-hidden);
        $last-in-selector: list.nth($selector, -1);
    
        @at-root #{$selector-prefix} ~ #{$last-in-selector} {
          @content;
        }
      }
    }
    
    @mixin margin-top($spacing: $paragraph-spacing) {
      --margin-top-spacing: #{$spacing};
      margin-bottom: 0;
    
      @include not-first-child {
        margin-top: var(--margin-top-spacing);
      }
    }