Skip to content

Design constraints with css classes and typescript validation and auto-completion #87

@kaaboaye

Description

@kaaboaye
  • Your full name: Mieszko Wawrzyniak
  • Target audience (beginner/intermediate/advanced/everyone): intermediate
  • Estimated duration: 20 min
  • Keywords:

<Title of your talk>

Design constraints with css classes and typescript validation and auto-completion

Title is WIP

The talk

One-sentence summary

I'll explain how you can leverage Typescript template literal types to create both run-time and compile time classes enabling your very own Tailwind-like developer experience.

What's the format — is it a case study, a live coding session, a workshop or something else?

case study

Tell us more about the talk

I'll most likely go through the following file and show step by step how Typescript's type system can infer types in various real time scenarios.

// register global styles
declare global {
  interface Window {
    sfrStyles?: HTMLStyleElement;
  }
}

const atomicClasses = (() => {
  const baseClasses = {
    hide: "display: none",
    block: "display: block",
    "inline-block": "display: inline-block",
    flex: "display: flex",
    "inline-flex": "display: inline-flex",

    "flex-wrap": "flex-wrap: wrap",

    flex1: "flex: 1",
    flex2: "flex: 2",
    flex3: "flex: 3",
    flex4: "flex: 4",
    flex5: "flex: 5",
    flex6: "flex: 6",

    capitalize: "text-transform: capitalize",
    lowercase: "text-transform: lowercase",
    uppercase: "text-transform: uppercase",

    "margin-0auto": "margin: 0 auto",
    "margin-left-auto": "margin-left: auto",
    "position-relative": "position: relative",
    "background-color-initial": "background-color: initial",
    "background-color-white": "background-color: white",
    "user-select-none": "user-select: none",
    "cursor-pointer": "cursor: pointer",
    "color-white": "color: white",

    "max-width400": "max-width: 400px",
    "max-width720": "max-width: 720px",

    "pointer-events-auto": "pointer-events: auto",

    "white-space-pre-wrap": "white-space: pre-wrap",
  } as const;

  function computeVariations<
    T1 extends string,
    T2 extends string,
    V1 extends string,
    V2 extends string
  >(
    [names, subNames]: readonly [readonly T1[], readonly T2[]],
    values: readonly (readonly [V1, V2])[]
  ) {
    return names.flatMap(
      (name) =>
        [
          ...values.map(
            ([valueName, value]) =>
              [`${name}${valueName}`, `${name}: ${value}`] as const
          ),
          ...subNames.flatMap((subName) =>
            values.map(
              ([valueName, value]) =>
                [
                  `${name}-${subName}${valueName}`,
                  `${name}-${subName}: ${value}`,
                ] as const
            )
          ),
        ] as const
    );
  }

  const sizes = [
    ["4", "4px"],
    ["6", "6px"],
    ["8", "8px"],
    ["12", "12px"],
    ["16", "16px"],
    ["18", "18px"],
    ["24", "24px"],
    ["32", "32px"],
    ["48", "48px"],
    ["64", "64px"],
  ] as const;

  const colors = (["blue", "green", "red", "gray"] as const).flatMap((color) =>
    (["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"] as const).flatMap(
      (step) => [`${color}-${step}`] as const
    )
  );

  const colorOptions = (
    [
      ["background", "background-color"],
      ["color", "color"],
    ] as const
  ).flatMap(([name, property]) =>
    colors.map(
      (color) => [`${name}-${color}`, `${property}: var(--${color})`] as const
    )
  );

  const sizeEntries = computeVariations(
    [
      ["width", "max-width", "min-width", "height", "max-height", "min-height"],
      [],
    ],
    [
      ...sizes,
      ["128", "128px"],
      ["100", "100%"],
      ["-min", "min-content"],
      ["-max", "max-content"],
    ]
  );

  const spacingEntries = computeVariations(
    [
      ["margin", "padding"],
      ["top", "bottom", "left", "right"],
    ],
    [["0", "0"], ...sizes]
  );

  const gapEntries = computeVariations(
    [["gap"], []],
    [...sizes, ["-items", "var(--gap-items)"], ["-cards", "var(--gap-cards)"]]
  );

  const sizedEntries = computeVariations(
    [["border-radius"], []],
    [
      ["6", "6px"],
      ["16", "16px"],
      ["100", "100%"],
    ]
  );

  const borderEntries = computeVariations(
    [["border"], ["right"]],
    [
      ["0", "0"],
      ["-none", "none"],
    ]
  );

  const flexDirectionEntries = computeVariations(
    [["flex-direction"], []],
    [
      ["-row", "row"],
      ["-column", "column"],
      ["-row-reverse", "row-reverse"],
      ["-column-reverse", "column-reverse"],
    ]
  );

  const contentEntries = computeVariations(
    [["justify-content"], []],
    [
      ["-space-between", "space-between"],
      ["-space-around", "space-around"],
      ["-center", "center"],
      ["-end", "end"],
      ["-flex-end", "flex-end"],
    ]
  );

  const itemsEntries = computeVariations(
    [["align-items", "align-self", "justify-items"], []],
    [
      ["-start", "start"],
      ["-center", "center"],
      ["-stretch", "stretch"],
      ["-end", "end"],
      ["-flex-end", "flex-end"],
    ]
  );

  const textAlignEntries = computeVariations(
    [["text-align"], []],
    [
      ["-left", "left"],
      ["-center", "center"],
      ["-right", "right"],
    ]
  );

  const overflowEntries = computeVariations(
    [["overflow"], ["x", "y"]],
    [
      ["-visible", "visible"],
      ["-hidden", "hidden"],
      ["-auto", "auto"],
    ]
  );

  const fontSizeEntries = computeVariations([["font-size"], []], sizes);

  type NameFromEntries<T extends readonly (readonly [string, string])[]> =
    T[number][0];

  const externalClasses = [
    "btn-hidable",
    "btn-icon-right",
    "pagination-hide-steps",
  ] as const;

  type Name =
    | keyof typeof baseClasses
    | NameFromEntries<typeof sizeEntries>
    | NameFromEntries<typeof colorOptions>
    | NameFromEntries<typeof spacingEntries>
    | NameFromEntries<typeof gapEntries>
    | NameFromEntries<typeof sizedEntries>
    | NameFromEntries<typeof flexDirectionEntries>
    | NameFromEntries<typeof contentEntries>
    | NameFromEntries<typeof itemsEntries>
    | NameFromEntries<typeof textAlignEntries>
    | NameFromEntries<typeof overflowEntries>
    | NameFromEntries<typeof borderEntries>
    | NameFromEntries<typeof fontSizeEntries>
    | typeof externalClasses[number];

  const classes = Object.freeze(
    Object.assign(
      Object.create(null) as object,
      baseClasses,
      Object.fromEntries(sizeEntries),
      Object.fromEntries(colorOptions),
      Object.fromEntries(spacingEntries),
      Object.fromEntries(gapEntries),
      Object.fromEntries(sizedEntries),
      Object.fromEntries(flexDirectionEntries),
      Object.fromEntries(contentEntries),
      Object.fromEntries(itemsEntries),
      Object.fromEntries(textAlignEntries),
      Object.fromEntries(overflowEntries),
      Object.fromEntries(borderEntries),
      Object.fromEntries(fontSizeEntries),
      Object.fromEntries(externalClasses.map((name) => [name, ""] as const))
    ) as { [key in Name]: string }
  );

  // register styles

  const styleContent = Object.entries(classes)
    .filter(([, style]) => style)
    .map(([className, style]) => `.sfr-${className} {${style} !important;}`)
    .join("\n");

  const textNode = document.createTextNode(styleContent);

  if (window.sfrStyles) {
    window.sfrStyles.firstChild?.remove();
    window.sfrStyles.appendChild(textNode);
  } else {
    window.sfrStyles = document.createElement("style");
    window.sfrStyles.appendChild(textNode);
    document.head.appendChild(window.sfrStyles);
  }

  return classes;
})();

type Name = keyof typeof atomicClasses;

// let's use arguments directly to prevent webpack from converting it to array
/* eslint-disable prefer-rest-params */
export const cssCommons = function cssCommons(): string {
  const parts: string[] = new Array(arguments.length * 2);
  let partsIdx = 0;

  // those loops aren't recommended because of readability but they are still the fastest way
  // to iterate over an array
  // eslint-disable-next-line no-plusplus
  for (let argumentsIdx = 0; argumentsIdx < arguments.length; argumentsIdx++) {
    // since app wont crash when unknown css classes are used we don't need this check
    // in production builds and webpack will remove this code when bundling
    if (process.env.NODE_ENV !== "production") {
      if (!((arguments[argumentsIdx] as Name) in atomicClasses)) {
        throw new Error(
          `Unknown atomic class. "${
            arguments[argumentsIdx] as Name
          }" is not available.`
        );
      }
    }

    // parts.push() is a no-go since we preallocate the array
    /* eslint-disable no-plusplus */
    parts[partsIdx++] = " sfr-";
    parts[partsIdx++] = arguments[argumentsIdx];
    /* eslint-enable no-plusplus */
  }

  // apply is faster then "".concat(...parts)
  return String.prototype.concat.apply("", parts);
} as (...classNames: readonly [Name, ...(readonly Name[])]) => string;
/* eslint-enable prefer-rest-params */

And explain how we've got from the code above the following results

image

image

You

Co-Funder and CTO at Surfer Local. https://surferlocal.com

A few words about yourself

https://www.linkedin.com/in/mieszko-wawrzyniak/

I'll work up something later if my talk is accepted.

How can we find you on social media?

It's best to email me.

Would you be willing to have a Q/A session after the talk?

Sure

Do you mind if we record the event?

Nope, go ahead

Is there anything we can help you with?

Nope

I was invited to post my proposal by @hasparus

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions