@design.estate/dees-element

A powerful custom element base class that extends Lit's LitElement with integrated theming, responsive CSS utilities, RxJS-powered directives, and DOM tooling — so you can build web components that look great and stay reactive out of the box.

Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.

Install

npm install @design.estate/dees-element
# or
pnpm install @design.estate/dees-element

This package ships as ESM and is written in TypeScript. Make sure your project targets ES2022+ with a modern module resolution strategy (e.g. NodeNext).

Usage

Everything you need is exported from the main entry point:

import {
  DeesElement,
  customElement,
  property,
  state,
  html,
  css,
  cssManager,
  directives,
} from '@design.estate/dees-element';

🧱 Creating a Custom Element

Extend DeesElement and apply the @customElement decorator:

import { DeesElement, customElement, html, css, cssManager } from '@design.estate/dees-element';

@customElement('my-button')
class MyButton extends DeesElement {
  static styles = [
    cssManager.defaultStyles,
    css`
      .btn {
        padding: 8px 16px;
        border-radius: 4px;
        background: ${cssManager.bdTheme('#0060df', '#3a8fff')};
        color: ${cssManager.bdTheme('#fff', '#fff')};
        border: none;
        cursor: pointer;
      }
    `,
  ];

  render() {
    return html`<button class="btn"><slot></slot></button>`;
  }
}

That single bdTheme() call generates a CSS variable that automatically flips between the bright and dark values when the user's theme changes — no manual toggling needed.

🧪 Demo Wrapper Utilities

Component demos can import the demotools subpath to register <dees-demowrapper>. The wrapper renders its slotted demo content and then runs runAfterRender, which is useful for demos that need to set up state, query rendered light-DOM children, or simulate user interaction after first render.

import { html } from '@design.estate/dees-element';
import { DeesDemoWrapper } from '@design.estate/dees-element/demotools';

export const demo = () => html`
  <dees-demowrapper
    .runAfterRender=${(wrapper: DeesDemoWrapper) => {
      const button = wrapper.querySelector('button');
      button?.dispatchEvent(new Event('click', { bubbles: true }));
    }}
  >
    <button>Demo action</button>
  </dees-demowrapper>
`;

🎨 Theme Management with cssManager

The singleton cssManager is the central hub for theming and responsive layout:

Method Purpose
cssManager.defaultStyles Base styles for consistent element rendering
cssManager.bdTheme(bright, dark) Returns a CSSResult that auto-switches between bright/dark values
cssManager.cssForDesktop(css, this?) Breakpoint for desktop; pass this for component-scoped
cssManager.cssForNotebook(css, this?) Breakpoint for notebook; pass this for component-scoped
cssManager.cssForTablet(css, this?) Breakpoint for tablet; pass this for component-scoped
cssManager.cssForPhablet(css, this?) Breakpoint for phablet; pass this for component-scoped
cssManager.cssForPhone(css, this?) Breakpoint for phone; pass this for component-scoped
cssManager.cssForConstraint({ maxWidth, minWidth }) Custom viewport-level constraint (curried)
cssManager.cssGridColumns(cols, gap) Generates CSS grid column widths

Example — responsive + themed styles:

@customElement('my-card')
class MyCard extends DeesElement {
  static styles = [
    cssManager.defaultStyles,
    css`
      :host {
        display: block;
        padding: 16px;
        background: ${cssManager.bdTheme('#ffffff', '#1e1e1e')};
        color: ${cssManager.bdTheme('#111', '#eee')};
        border-radius: 8px;
      }
    `,
    cssManager.cssForPhone(css`
      :host { padding: 8px; }
    `),
  ];

  render() {
    return html`<slot></slot>`;
  }
}

📦 Container-Responsive Components

For components that need to respond to their own width (not the viewport), use the @containerResponsive() decorator and pass this as the second argument to cssManager.cssFor*:

import {
  DeesElement, customElement, html, css, cssManager,
  containerResponsive,
} from '@design.estate/dees-element';

@containerResponsive()
@customElement('my-stats-grid')
class MyStatsGrid extends DeesElement {
  static styles = [
    cssManager.defaultStyles,
    css`.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }`,

    // Component-level: when THIS element is narrower than tablet width
    cssManager.cssForTablet(css`
      .grid { grid-template-columns: repeat(2, 1fr); }
    `, this),

    // Viewport-level: when the browser window is phone-sized
    cssManager.cssForPhone(css`
      .grid { grid-template-columns: 1fr; }
    `),

    // Component-level with custom width constraint
    this.cssForConstraint({ maxWidth: 500 })(css`
      .grid { gap: 8px; }
    `),
  ];

  render() {
    return html`<div class="grid"><slot></slot></div>`;
  }
}

How it works:

API Scope Generated CSS
cssManager.cssForPhablet(css) Viewport @media + @container wccToolsViewport
cssManager.cssForPhablet(css, this) Component @container <tag-name> only
cssManager.cssForConstraint({maxWidth:800})(css) Viewport @media + @container wccToolsViewport
this.cssForConstraint({maxWidth:500})(css) Component @container <tag-name> only
@containerResponsive() Decorator Sets container-type: inline-size + container-name on :host

The @containerResponsive() decorator is required for component-scoped queries — it establishes the CSS containment context on :host.

⚡ Reactive Properties & State

Use the standard Lit decorators, re-exported for convenience:

import { DeesElement, customElement, property, state, html } from '@design.estate/dees-element';

@customElement('my-counter')
class MyCounter extends DeesElement {
  @property({ type: String })
  accessor label = 'Count';

  @state()
  accessor count = 0;

  render() {
    return html`
      <button @click=${() => this.count++}>
        ${this.label}: ${this.count}
      </button>
    `;
  }
}

Note: This library uses the TC39 standard decorators with the accessor keyword for decorated class properties.

🔄 Theme Change Callbacks

DeesElement tracks the current theme via the goBright property and exposes an optional themeChanged callback:

@customElement('theme-aware')
class ThemeAware extends DeesElement {
  protected themeChanged(goBright: boolean) {
    console.log(goBright ? 'Switched to bright' : 'Switched to dark');
  }

  render() {
    return html`<p>Current theme: ${this.goBright ? 'bright' : 'dark'}</p>`;
  }
}

goBright is reflected as the gobright attribute on the host element, so themes can be styled in plain CSS without JavaScript or per-value bdTheme() pairs:

public static styles = [
  css`
    :host {
      background: #000;
    }
    :host([gobright]) {
      background: #fff;
    }
  `,
];

The attribute updates automatically whenever the theme switches.

🚀 Lifecycle Helpers

DeesElement adds lifecycle utilities on top of LitElement:

@customElement('my-widget')
class MyWidget extends DeesElement {
  constructor() {
    super();

    // Runs for each connection, after the previous connection has finished cleanup.
    this.registerStartupFunction(async (signal) => {
      if (signal.aborted) return;
      console.log('Widget connected!');
    });

    // Runs when the element is disconnected — perfect for cleanup
    this.registerGarbageFunction(() => {
      console.log('Widget removed');
    });
  }

  render() {
    return html`<p>Hello World</p>`;
  }
}

Additionally, this.elementDomReady is a promise that resolves after firstUpdated, which is handy when you need to wait for the initial render:

await this.elementDomReady;
// The element's shadow DOM is now fully rendered

Startup hooks receive an AbortSignal that is aborted synchronously on removal. Use it to cancel work that supports cancellation. Hooks already running are allowed to settle; garbage hooks then finish before the next connection starts. Register hooks once, usually in the constructor. Resources created by a startup hook belong to that connection.

Lit directives disconnect and rxSubscriptions unsubscribe immediately on removal. Subscriptions added by a finishing startup hook are drained before cleanup completes. Every garbage hook is attempted even if another throws; the returned disconnect promise rejects with an AggregateError, and later connections can still run. Subclasses should call the superclass lifecycle method immediately and await its returned promise before starting connection-specific asynchronous work.

The resolve directive ignores stale promises, retains pending work across removal, and renders the current promise after reconnection even if it settled while detached.

🌍 Languages with localize and provideI18n

A component speaks the words of its package's catalog, a typed @push.rocks/smarti18n catalog of ICU messages. The app decides the language once, by providing an I18n instance; every component below re-renders when it changes.

1. The component package defines its catalog (see the smarti18n readme for messages, translations and checks):

import { defineCatalog } from '@push.rocks/smarti18n';

export const messages = defineCatalog({
  namespace: '@my/components',
  source: 'en',
  messages: {
    counter: 'Step {current, number} of {total, number}',
    help: 'Read the <link>guide</link>.',
  },
});
messages.addTranslation('de', () => import('./messages.de.js'));

2. A component takes its words with localize:

import { DeesElement, customElement, html, localize } from '@design.estate/dees-element';
import { messages } from './messages.js';

@customElement('my-stepper')
class MyStepper extends DeesElement {
  private t = localize(this, messages);

  render() {
    return html`
      <span>${this.t('counter', { current: 2, total: 4 })}</span>
      <p>${this.t.rich('help', { link: (chunks) => html`<a href="/guide">${chunks}</a>` })}</p>
      <small>${this.t.format.number(1234.5)}</small>
    `;
  }
}
  • Keys and parameters are typed: this.t('counter', { current: 2 }) and this.t('countr') are compile errors.
  • this.t.format writes numbers, money, dates, lists and relative times in the formats the catalog's messages are written in, so it always agrees with the numbers inside messages, and this.t.i18n is the instance the element speaks now.

3. The app provides its instance, on <html> so that elements appended to document.body (modals, menus) find it too:

import { createI18n } from '@push.rocks/smarti18n';
import { provideI18n } from '@design.estate/dees-element';

const i18n = createI18n({ locale: 'de-DE' });
provideI18n(document.documentElement, i18n, { reflect: true }); // keeps <html lang dir> in step

await i18n.setLocale('en-GB'); // every localized element re-renders in English

How it behaves:

  • Without a provider an element speaks its catalog's source language (smarti18n's sourceI18n). A provider that appears later is still found, so the order of setup does not matter.

  • A fallback of its own: localize(this, messages, { fallback }) speaks fallback instead of sourceI18n while no provider serves the element, and again when its provider is disposed. A component library keeps formats of its own that way until a host provides a language, for example numbers without grouping, in messages and t.format alike:

    import { createSourceI18n, Formats } from '@push.rocks/smarti18n';
    
    class UngroupedFormats extends Formats {
      public override intlNumberFormat(options: Intl.NumberFormatOptions): Intl.NumberFormat {
        return super.intlNumberFormat({ useGrouping: false, ...options });
      }
    }
    const neutral = createSourceI18n({ createFormats: (settings) => new UngroupedFormats(settings) });
    
    // in the component
    private t = localize(this, messages, { fallback: neutral });
    
  • A subtree can speak another language: provideI18n(previewElement, documentI18n) serves the elements below previewElement (a document preview, a demo side by side). On a DeesElement host the provider lives with the host; reflect sets lang and dir on it while it is connected. provider.i18n = other hands the subtree another instance. provider.dispose() stops a provider and restores its host's lang and dir; a provider on a plain element, such as <html>, lives until then.

  • An overlay can speak its opener's language: a provider serves the elements below its host, never the host's own localize() words. provideI18n(overlay, openerI18n, { includeHost: true }) serves the host too: its own words and the content it renders speak openerI18n apart from the page, follow a locale switch or provider.i18n = other, and return to the page's language on provider.dispose(). It works whether the host is connected already or connects later.

  • No render before the words are in. DeesElement.scheduleUpdate() waits until the catalogs an element uses are loaded for its language, so a lazily defined catalog never shows its source words first. It waits only while words are still to load: with every word loaded, an update is synchronous, so a localized child that a parent renders is current once the parent's updateComplete resolves. Words that fail to load reject updateComplete (with the render's own error as well, in an AggregateError); the next update tries again. A subclass that overrides scheduleUpdate() must call super.scheduleUpdate().

  • No leaks. An element subscribes to its provider and its instance on connect and unsubscribes on disconnect.

  • Right to left. i18n.direction is rtl for Arabic, Hebrew, Persian and the pseudo-locale ar-XB; with reflect, dir="rtl" flips the page. en-XA (createI18n({ locale: 'en-XA', pseudo: true })) shows clipped or untranslated text at a glance.

  • The context is i18nContext (the Web Components context protocol, via @lit/context), keyed by Symbol.for('dees.i18n'), so every copy of this package in a page shares it.

📡 Directives

The directives namespace includes powerful template helpers, accessible via directives.*:

resolve — Render a Promise

import { html, directives } from '@design.estate/dees-element';

render() {
  return html`${directives.resolve(this.fetchData())}`;
}

resolveExec — Resolve a lazy async function

render() {
  return html`${directives.resolveExec(() => this.loadContent())}`;
}

subscribe — Render an RxJS Observable

import { html, directives } from '@design.estate/dees-element';

render() {
  return html`<span>${directives.subscribe(this.count$)}</span>`;
}

subscribeWithTemplate — Observable + template transform

render() {
  return html`
    ${directives.subscribeWithTemplate(
      this.items$,
      (items) => html`<ul>${items.map(i => html`<li>${i}</li>`)}</ul>`
    )}
  `;
}

Re-exported Lit directives

The directives namespace also re-exports these commonly used Lit directives:

  • until — render a placeholder while a promise resolves
  • asyncAppend — append values from an async iterable
  • keyed — force re-creation of a template when a key changes
  • repeat — efficiently render lists with identity tracking

📦 Full Export Reference

Export Description
DeesElement Base class for custom elements
cssManager Singleton CssManager instance
customElement Class decorator to register elements
property Reactive property decorator
state Internal state decorator
query, queryAll, queryAsync Shadow DOM query decorators
html Lit html template tag
css Lit css template tag
unsafeCSS Create CSSResult from a string
unsafeHTML Render raw HTML in templates
render Lit render function
static / unsafeStatic Static html template helpers
containerResponsive Decorator that adds CSS containment to :host
domtools DOM tooling utilities
directives All directives (resolve, subscribe, etc.)
localize, ILocalizer (type), ILocalizeOptions (type) A catalog's typed words for an element, in the provided language or its fallback
provideI18n, I18nProvider, IProvideI18nOptions (type) Provide an I18n to a page or a subtree
i18nContext The context an I18n is provided under
rxjs (type) RxJS type re-export
DeesDemoWrapper from @design.estate/dees-element/demotools Demo helper custom element for post-render callbacks

In dees-element 3, the public domtools namespace exposes dees-domtools 3 and TypedRequest 8. Consumers upgrading from dees-element 2 must use the TypedRequest 8 request envelope, including its per-attempt requestInstanceId semantics.

This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license file.

Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

Trademarks

This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.

Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.

Company Information

Task Venture Capital GmbH Registered at District Court Bremen HRB 35230 HB, Germany

For any legal inquiries or further information, please contact us via email at hello@task.vc.

By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

S
Description
a custom element class extending lit element class
Readme
2.7 MiB
Languages
TypeScript 100%