firsthand

@firsthandjs/styled

Reference index · 2.06 kB gzip · depends on @firsthandjs/dom

Styled components, compiled once per template rather than once per instance. Guide: Styling.


styled

const styled: {
  [T in keyof JSX.IntrinsicElements]: StyledFactory<ElementProps<T>>;
} & (<P>(base: Component<P>) => StyledFactory<P>);

interface StyledFactory<Base> {
  <P = unknown>(
    strings: TemplateStringsArray,
    ...values: Interpolation<P & Base>[]
  ): Component<P & Base>;
}

type StyledProps<P> = P & { readonly theme: Theme };
type Interpolation<P> = string | number | CssFragment | ((props: StyledProps<P>) => unknown);
type ElementProps<T extends string> = T extends keyof JSX.IntrinsicElements
  ? JSX.IntrinsicElements[T]
  : Record<string, unknown>;
const Button = styled.button<{ $primary?: boolean }>`
  padding: 0.5rem 1rem;
  color: ${(props) => (props.$primary === true ? props.theme.onPrimary : props.theme.text)};
  &:hover {
    filter: brightness(1.1);
  }
`;

P defaults to unknown rather than {}, because an empty record’s index signature would type every other prop — children included — as never.

Two kinds of interpolation. One that produces a value becomes a CSS custom property: one rule serves every instance and an update writes one property on one element. One that produces a block (a css fragment, or several declarations) becomes part of the class, and a distinct result gets its own class. Native CSS nesting is used as written; nothing is flattened.

Prop forwarding: a prop is written to the element when it exists on the element, or is data-*, aria-* or an event, or is spelled prop: or attr:. children, class and any $-prefixed prop are not. styled-components ships a list of every HTML attribute to answer this question; the element already knows.

prop: and attr: pass through because they are not a guess about what the element accepts — they are the author saying which of the two to write. That is what a custom element usually needs: a component library’s field keeps its own state, so writing the value attribute does nothing once somebody has typed in it, and clearing the field from code needs prop:value. It is also the only reliable way to reach a custom element that has not upgraded yet, because until it does, it has none of its own properties for name in element to find.

styled(Component) wraps any component, including a React one bridged by @firsthandjs/react, and a styled component can be styled again. A wrapped component has no element to carry custom properties, so every interpolation resolves into the class.

Restyling wins on specificity. Each wrapping level repeats its own class in the selector — .a, then .b.b, then .c.c.c — so the outer declaration beats the inner one whatever order the rules reached the stylesheet in. Rule order could not decide it: a component with a block interpolation inserts its rule when it first renders, and a wrapper renders before what it wraps.

css

function css(strings: TemplateStringsArray, ...values: unknown[]): CssFragment;
function isFragment(value: unknown): value is CssFragment;

interface CssFragment {
  readonly strings: TemplateStringsArray;
  readonly values: readonly unknown[];
}

A reusable chunk. Interpolate it into a styled component, or return it from an interpolation function to add a whole block conditionally.

Themes

interface FirsthandTheme {} // empty; an application declares its own
type Theme = [keyof FirsthandTheme] extends [never]
  ? Readonly<Record<string, unknown>>
  : Readonly<FirsthandTheme>;

const ThemeContext: Context<Theme>; // defaults to an empty theme
function useTheme(): ReadonlyCell<Theme>;

Declare the theme once and every interpolation is typed:

// The empty import matters: a file may only augment a module it imports.
import type {} from '@firsthandjs/styled';

declare module '@firsthandjs/styled' {
  interface FirsthandTheme {
    background: string;
    text: string;
  }
}

A project that declares nothing keeps the indexed form.

provide(ThemeContext, signal({ text: '#111', onPrimary: '#fff' }));

Every interpolation function is given props.theme, so components rarely call useTheme themselves.

Global styles and keyframes

function createGlobalStyle(
  strings: TemplateStringsArray,
  ...values: Interpolation<unknown>[]
): Component<Record<string, never>>;

function keyframes(strings: TemplateStringsArray, ...values: unknown[]): string;

A global style is attached while the component is mounted and replaced when the theme changes. Two instances of the same global style share one rule, which is refcounted: the rule is removed when the last one unmounts, not the first.

keyframes returns the generated animation name and cannot interpolate functions — an animation has no props to read.

The sheet

function hash(text: string): string;
function resetStyles(): void;

resetStyles() drops every rule this package inserted. It is for tests, so that one test’s classes cannot be seen by the next; calling it in an application removes the styles of everything currently mounted.

hash is the same function used to name classes, exported so a test can assert a class name without hard-coding it.