aurelia/aurelia

View on GitHub
packages/runtime-html/src/templating/styles.ts

Summary

Maintainability
B
4 hrs
Test Coverage
B
84%
import { IContainer, createLookup, noop, own, resolve, toArray } from '@aurelia/kernel';
import { AppTask } from '../app-task';
import { ICssClassMapping } from '../dom';
import { IPlatform } from '../platform';
import { createInterface, instanceRegistration } from '../utilities-di';

import type { IRegistry } from '@aurelia/kernel';
import { ITemplateCompilerHooks, TemplateCompilerHooks } from '@aurelia/template-compiler';
import { objectAssign } from '../utilities';

/**
 * There are 2 implementations of CSS registry: css module registry and shadow dom registry.
 *
 * - CSS registry alters the way class bindings work via altering templates and register interfaces that will alter bindings to class attribute.
 *
 * - Shadow dom registry regisiters some interfaces with the custom element container to handle shadow dom styles.
 * Shadow DOM abtraction summary:
 * CSS registry ---(register)---> IShadowDOMStyleFactory ---(createStyles)---> IShadowDOMStyles ---(applyTo)---> ShadowRoot
 */

/**
 * create a registry to register CSS module handling for a custom element.
 * The resulting registry can be registered as a dependency of a custom element.
 */
export function cssModules(...modules: (Record<string, string>)[]): CSSModulesProcessorRegistry {
  return new CSSModulesProcessorRegistry(modules);
}

export class CSSModulesProcessorRegistry implements IRegistry {
  public constructor(
    private readonly modules: Record<string, string>[],
  ) {}

  public register(container: IContainer): void {
    let existingMapping = container.get(own(ICssClassMapping));
    if (existingMapping == null) {
      container.register(
        instanceRegistration(ICssClassMapping, existingMapping = createLookup()),
      );
    }
    /* istanbul ignore if */
    if (__DEV__) {
      for (const mapping of this.modules) {
        for (const originalClass in mapping) {
          if (originalClass in existingMapping) {
            // eslint-disable-next-line no-console
            console.warn(`[DEV:aurelia] CSS class mapping for class "${originalClass}": "${mapping[originalClass]}" is overridden by "${existingMapping[originalClass]}"`);
          }
          existingMapping[originalClass] = mapping[originalClass];
        }
      }
    } else {
      objectAssign(existingMapping, ...this.modules);
    }

    class CompilingHook implements ITemplateCompilerHooks {
      public compiling(template: HTMLElement): void {
        const isTemplate = template.tagName === 'TEMPLATE';
        const container = isTemplate
          ? (template as HTMLTemplateElement).content
          : template;
        const plainClasses = [template, ...toArray(container.querySelectorAll('[class]'))];
        for (const element of plainClasses) {
          const classes = element.getAttributeNode('class')!;
          // we always include container, so there's a case where classes is null
          if (classes == null) {
            continue;
          }
          const newClasses = classes.value.split(/\s+/g).map(x => existingMapping![x] || x).join(' ');
          classes.value = newClasses;
        }
      }
    }

    container.register(TemplateCompilerHooks.define(CompilingHook));
  }
}

/**
 * Creates a registry to register shadow dom styles handling for a custom element.
 * The resulting registry can be registered as a dependency of a custom element.
 */
export function shadowCSS(...css: (string | CSSStyleSheet)[]): ShadowDOMRegistry {
  return new ShadowDOMRegistry(css);
}

export interface IShadowDOMStyleFactory {
  createStyles(localStyles: (string | CSSStyleSheet)[], sharedStyles: IShadowDOMStyles | null): IShadowDOMStyles;
}

export const IShadowDOMStyleFactory = /*@__PURE__*/createInterface<IShadowDOMStyleFactory>('IShadowDOMStyleFactory', x => x.cachedCallback(handler => {
  if (AdoptedStyleSheetsStyles.supported(handler.get(IPlatform))) {
    return handler.get(AdoptedStyleSheetsStylesFactory);
  }
  return handler.get(StyleElementStylesFactory);
}));

export class ShadowDOMRegistry implements IRegistry {
  public constructor(
    private readonly css: (string | CSSStyleSheet)[],
  ) { }

  public register(container: IContainer): void {
    const sharedStyles = container.get(IShadowDOMGlobalStyles);
    const factory = container.get(IShadowDOMStyleFactory);
    container.register(instanceRegistration(IShadowDOMStyles, factory.createStyles(this.css, sharedStyles)));
  }
}

class AdoptedStyleSheetsStylesFactory implements IShadowDOMStyleFactory {
  private readonly p = resolve(IPlatform);
  private readonly cache = new Map<string, CSSStyleSheet>();

  public createStyles(localStyles: (string | CSSStyleSheet)[], sharedStyles: IShadowDOMStyles | null): IShadowDOMStyles {
    return new AdoptedStyleSheetsStyles(this.p, localStyles, this.cache, sharedStyles);
  }
}

// not really needed nowadays since all browsers support adopted style sheet
// though keep it here for a bit longer before removing
/* istanbul ignore next */
class StyleElementStylesFactory implements IShadowDOMStyleFactory {
  private readonly p = resolve(IPlatform);

  public createStyles(localStyles: string[], sharedStyles: IShadowDOMStyles | null): IShadowDOMStyles {
    return new StyleElementStyles(this.p, localStyles, sharedStyles);
  }
}

type HasAdoptedStyleSheets = ShadowRoot & {
  adoptedStyleSheets: CSSStyleSheet[];
};

export interface IShadowDOMStyles {
  applyTo(shadowRoot: ShadowRoot): void;
}

export const IShadowDOMStyles = /*@__PURE__*/createInterface<IShadowDOMStyles>('IShadowDOMStyles');
export const IShadowDOMGlobalStyles = /*@__PURE__*/createInterface<IShadowDOMStyles>('IShadowDOMGlobalStyles', x => x.instance({ applyTo: noop }));

export class AdoptedStyleSheetsStyles implements IShadowDOMStyles {
  private readonly styleSheets: CSSStyleSheet[];

  public constructor(
    p: IPlatform,
    localStyles: (string | CSSStyleSheet)[],
    styleSheetCache: Map<string, CSSStyleSheet>,
    private readonly sharedStyles: IShadowDOMStyles | null = null
  ) {
    this.styleSheets = localStyles.map(x => {
      let sheet: CSSStyleSheet | undefined;

      if (x instanceof p.CSSStyleSheet) {
        sheet = x;
      } else {
        sheet = styleSheetCache.get(x);

        if (sheet === void 0) {
          sheet = new p.CSSStyleSheet();
          sheet.replaceSync(x);
          styleSheetCache.set(x, sheet);
        }
      }

      return sheet;
    });
  }

  public static supported(p: IPlatform): boolean {
    return 'adoptedStyleSheets' in p.ShadowRoot.prototype;
  }

  public applyTo(shadowRoot: HasAdoptedStyleSheets): void {
    if (this.sharedStyles !== null) {
      this.sharedStyles.applyTo(shadowRoot);
    }

    // https://wicg.github.io/construct-stylesheets/
    // https://developers.google.com/web/updates/2019/02/constructable-stylesheets
    shadowRoot.adoptedStyleSheets = [
      ...shadowRoot.adoptedStyleSheets,
      ...this.styleSheets
    ];
  }
}

export class StyleElementStyles implements IShadowDOMStyles {
  public constructor(
    private readonly p: IPlatform,
    private readonly localStyles: string[],
    private readonly sharedStyles: IShadowDOMStyles | null = null
  ) { }

  public applyTo(shadowRoot: ShadowRoot): void {
    const styles = this.localStyles;
    const p = this.p;

    for (let i = styles.length - 1; i > -1; --i) {
      const element = p.document.createElement('style');
      element.innerHTML = styles[i];
      shadowRoot.prepend(element);
    }

    if (this.sharedStyles !== null) {
      this.sharedStyles.applyTo(shadowRoot);
    }
  }
}

export interface IShadowDOMConfiguration {
  sharedStyles?: (string | CSSStyleSheet)[];
}

export const StyleConfiguration = {
  shadowDOM(config: IShadowDOMConfiguration): IRegistry {
    return AppTask.creating(IContainer, container => {
      if (config.sharedStyles != null) {
        const factory = container.get(IShadowDOMStyleFactory);
        container.register(instanceRegistration(IShadowDOMGlobalStyles, factory.createStyles(config.sharedStyles, null)));
      }
    });
  }
};