Files
dees-catalog/readme.playbook.md
2025-07-04 18:42:53 +00:00

18 KiB

UI Components Playbook

This playbook provides comprehensive guidance for creating and maintaining UI components in the @design.estate/dees-catalog library. Follow these patterns and best practices to ensure consistency, maintainability, and quality.

Table of Contents

  1. Component Creation Checklist
  2. Architectural Patterns
  3. Component Types and Base Classes
  4. Theming System
  5. Event Handling
  6. State Management
  7. Form Components
  8. Overlay Components
  9. Complex Components
  10. Performance Optimization
  11. Focus Management
  12. Demo System
  13. Common Pitfalls and Anti-patterns
  14. Code Examples

Component Creation Checklist

When creating a new component, follow this checklist:

  • Choose the appropriate base class (DeesElement or DeesInputBase)
  • Use @customElement('dees-componentname') decorator
  • Implement consistent theming with cssManager.bdTheme()
  • Create demo function in separate .demo.ts file
  • Export component from ts_web/elements/index.ts
  • Use proper TypeScript types and interfaces (prefix with I for interfaces, T for types)
  • Implement proper event handling with bubbling and composition
  • Consider mobile responsiveness
  • Add focus states for accessibility
  • Clean up resources in destroy() method
  • Follow lowercase naming convention for files
  • Add z-index registry support if it's an overlay component

Architectural Patterns

Base Component Structure

import { customElement, property, state, css, TemplateResult, html } from '@design.estate/dees-element';
import { DeesElement } from '@design.estate/dees-element';
import * as cssManager from './00colors.js';
import * as demoFunc from './dees-componentname.demo.js';

@customElement('dees-componentname')
export class DeesComponentName extends DeesElement {
  // Static demo reference
  public static demo = demoFunc.demoFunc;

  // Public properties (reactive, can be set via attributes)
  @property({ type: String })
  public label: string = '';

  @property({ type: Boolean, reflect: true })
  public disabled: boolean = false;

  // Internal state (reactive, but not exposed as attributes)
  @state()
  private internalState: string = '';

  // Static styles with theme support
  public static styles = [
    cssManager.defaultStyles,
    css`
      :host {
        display: block;
        background: ${cssManager.bdTheme('#ffffff', '#09090b')};
      }
    `
  ];

  // Render method
  public render(): TemplateResult {
    return html`
      <div class="main-container">
        <!-- Component content -->
      </div>
    `;
  }

  // Lifecycle methods
  public connectedCallback() {
    super.connectedCallback();
    // Setup that needs DOM access
  }

  public async firstUpdated() {
    // One-time initialization after first render
  }

  // Cleanup
  public destroy() {
    // Clean up listeners, observers, registrations
    super.destroy();
  }
}

Advanced Patterns

1. Separation of Concerns (Complex Components)

For complex components like WYSIWYG editors, separate concerns into handler classes:

export class DeesComplexComponent extends DeesElement {
  // Orchestrator pattern - main component coordinates handlers
  private inputHandler: InputHandler;
  private stateHandler: StateHandler;
  private renderHandler: RenderHandler;

  constructor() {
    super();
    this.inputHandler = new InputHandler(this);
    this.stateHandler = new StateHandler(this);
    this.renderHandler = new RenderHandler(this);
  }
}

2. Singleton Pattern (Global Components)

For global UI elements like menus:

export class DeesGlobalMenu extends DeesElement {
  private static instance: DeesGlobalMenu;

  public static getInstance(): DeesGlobalMenu {
    if (!DeesGlobalMenu.instance) {
      DeesGlobalMenu.instance = new DeesGlobalMenu();
      document.body.appendChild(DeesGlobalMenu.instance);
    }
    return DeesGlobalMenu.instance;
  }
}

3. Registry Pattern (Z-Index Management)

Use centralized registries for global state:

class ComponentRegistry {
  private static instance: ComponentRegistry;
  private registry = new WeakMap<HTMLElement, number>();
  
  public register(element: HTMLElement, value: number) {
    this.registry.set(element, value);
  }
  
  public unregister(element: HTMLElement) {
    this.registry.delete(element);
  }
}

Component Types and Base Classes

Standard Component (extends DeesElement)

Use for most UI components:

  • Buttons, badges, icons
  • Layout components
  • Data display components
  • Overlay components

Form Input Component (extends DeesInputBase)

Use for all form inputs:

  • Text inputs, dropdowns, checkboxes
  • Date pickers, file uploads
  • Rich text editors

Required implementations:

export class DeesInputCustom extends DeesInputBase<ValueType> {
  // Required: Get current value
  public getValue(): ValueType {
    return this.value;
  }

  // Required: Set value programmatically
  public setValue(value: ValueType): void {
    this.value = value;
    this.changeSubject.next(this); // Notify form
  }

  // Optional: Custom validation
  public async validate(): Promise<boolean> {
    // Custom validation logic
    return true;
  }
}

Theming System

DO: Use Theme Functions

Always use cssManager.bdTheme() for colors that change between themes:

// ✅ CORRECT
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
color: ${cssManager.bdTheme('#000000', '#ffffff')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333333')};

// ❌ INCORRECT
background: #ffffff; // Hard-coded color
color: var(--custom-color); // Custom CSS variable

DO: Use Consistent Color Values

Reference shared color constants when possible:

// From 00colors.ts
background: ${cssManager.bdTheme(colors.bright.background, colors.dark.background)};

Event Handling

DO: Dispatch Custom Events Properly

// ✅ CORRECT - Events bubble and cross shadow DOM
this.dispatchEvent(new CustomEvent('dees-componentname-change', {
  detail: { value: this.value },
  bubbles: true,
  composed: true
}));

// ❌ INCORRECT - Event won't propagate properly
this.dispatchEvent(new CustomEvent('change', {
  detail: { value: this.value }
  // Missing bubbles and composed
}));

DO: Use Event Delegation

For dynamic content, use event delegation:

// ✅ CORRECT - Single listener for all items
this.addEventListener('click', (e: MouseEvent) => {
  const item = (e.target as HTMLElement).closest('.item');
  if (item) {
    this.handleItemClick(item);
  }
});

// ❌ INCORRECT - Multiple listeners
this.items.forEach(item => {
  item.addEventListener('click', () => this.handleItemClick(item));
});

State Management

DO: Use Appropriate Property Decorators

// Public API - use @property
@property({ type: String })
public label: string;

// Internal state - use @state
@state()
private isLoading: boolean = false;

// Reflect to attribute when needed
@property({ type: Boolean, reflect: true })
public disabled: boolean = false;

DON'T: Manipulate State in Render

// ❌ INCORRECT - Side effects in render
public render() {
  this.counter++; // Don't modify state
  return html`<div>${this.counter}</div>`;
}

// ✅ CORRECT - Pure render function
public render() {
  return html`<div>${this.counter}</div>`;
}

Form Components

DO: Extend DeesInputBase

All form inputs must extend the base class:

export class DeesInputNew extends DeesInputBase<string> {
  // Inherits: key, label, value, required, disabled, validationState
}

DO: Emit Changes Consistently

private handleInput(e: Event) {
  this.value = (e.target as HTMLInputElement).value;
  this.changeSubject.next(this); // Notify form system
}

DO: Support Standard Form Properties

// All form inputs should support:
@property() public key: string;
@property() public label: string;
@property() public required: boolean = false;
@property() public disabled: boolean = false;
@property() public validationState: 'valid' | 'warn' | 'invalid';

Overlay Components

DO: Use Z-Index Registry

Never hardcode z-index values:

// ✅ CORRECT
import { zIndexRegistry } from './00zindex.js';

public async show() {
  this.modalZIndex = zIndexRegistry.getNextZIndex();
  zIndexRegistry.register(this, this.modalZIndex);
  this.style.zIndex = `${this.modalZIndex}`;
}

public async hide() {
  zIndexRegistry.unregister(this);
}

// ❌ INCORRECT
public async show() {
  this.style.zIndex = '9999'; // Hardcoded z-index
}

DO: Use Window Layers

For modal backdrops:

import { DeesWindowLayer } from './dees-windowlayer.js';

private windowLayer: DeesWindowLayer;

public async show() {
  this.windowLayer = new DeesWindowLayer();
  this.windowLayer.zIndex = zIndexRegistry.getNextZIndex();
  document.body.append(this.windowLayer);
}

Complex Components

DO: Use Handler Classes

For complex logic, separate into specialized handlers:

// wysiwyg/handlers/input.handler.ts
export class InputHandler {
  constructor(private component: DeesInputWysiwyg) {}
  
  public handleInput(event: InputEvent) {
    // Specialized input handling
  }
}

// Main component orchestrates
export class DeesInputWysiwyg extends DeesInputBase {
  private inputHandler = new InputHandler(this);
}

DO: Use Programmatic Rendering

For performance-critical updates that shouldn't trigger re-renders:

// ✅ CORRECT - Direct DOM manipulation when needed
private updateBlockContent(blockId: string, content: string) {
  const blockElement = this.shadowRoot.querySelector(`#${blockId}`);
  if (blockElement) {
    blockElement.textContent = content; // Direct update
  }
}

// ❌ INCORRECT - Triggering full re-render
private updateBlockContent(blockId: string, content: string) {
  this.blocks.find(b => b.id === blockId).content = content;
  this.requestUpdate(); // Unnecessary re-render
}

Performance Optimization

DO: Debounce Expensive Operations

private resizeTimeout: number;

private handleResize = () => {
  clearTimeout(this.resizeTimeout);
  this.resizeTimeout = window.setTimeout(() => {
    this.updateLayout();
  }, 250);
};

DO: Use Observers Efficiently

// Clean up observers
public disconnectedCallback() {
  super.disconnectedCallback();
  this.resizeObserver?.disconnect();
  this.mutationObserver?.disconnect();
}

DO: Implement Virtual Scrolling

For large lists:

// Only render visible items
private getVisibleItems() {
  const scrollTop = this.scrollContainer.scrollTop;
  const containerHeight = this.scrollContainer.clientHeight;
  const itemHeight = 50;
  
  const startIndex = Math.floor(scrollTop / itemHeight);
  const endIndex = Math.ceil((scrollTop + containerHeight) / itemHeight);
  
  return this.items.slice(startIndex, endIndex);
}

Focus Management

DO: Handle Focus Timing

// ✅ CORRECT - Wait for render
async focusInput() {
  await this.updateComplete;
  await new Promise(resolve => requestAnimationFrame(resolve));
  this.inputElement?.focus();
}

// ❌ INCORRECT - Focus too early
focusInput() {
  this.inputElement?.focus(); // Element might not exist
}

DO: Prevent Focus Loss

// For global menus
constructor() {
  super();
  // Prevent focus loss when clicking menu
  this.addEventListener('mousedown', (e) => {
    e.preventDefault();
  });
}

DO: Implement Blur Debouncing

private blurTimeout: number;

private handleBlur = () => {
  clearTimeout(this.blurTimeout);
  this.blurTimeout = window.setTimeout(() => {
    // Check if truly blurred
    if (!this.contains(document.activeElement)) {
      this.handleTrueBlur();
    }
  }, 100);
};

Demo System

DO: Create Comprehensive Demos

Every component needs a demo:

// dees-button.demo.ts
import { html } from '@design.estate/dees-element';

export const demoFunc = () => html`
  <dees-button>Default Button</dees-button>
  <dees-button type="primary">Primary Button</dees-button>
  <dees-button type="danger" disabled>Disabled Danger</dees-button>
`;

// In component file
import * as demoFunc from './dees-button.demo.js';

export class DeesButton extends DeesElement {
  public static demo = demoFunc.demoFunc;
}

DO: Include All Variants

Show all component states and variations in demos:

  • Default state
  • Different types/variants
  • Disabled state
  • Loading state
  • Error states
  • Edge cases (long text, empty content)

Common Pitfalls and Anti-patterns

DON'T: Hardcode Z-Index Values

// ❌ WRONG
this.style.zIndex = '9999';

// ✅ CORRECT
this.style.zIndex = `${zIndexRegistry.getNextZIndex()}`;

DON'T: Skip Base Classes

// ❌ WRONG - Form input without base class
export class DeesInputCustom extends DeesElement {
  // Missing standard form functionality
}

// ✅ CORRECT
export class DeesInputCustom extends DeesInputBase<string> {
  // Inherits all form functionality
}

DON'T: Forget Theme Support

// ❌ WRONG
background-color: #ffffff;
color: #000000;

// ✅ CORRECT
background-color: ${cssManager.bdTheme('#ffffff', '#09090b')};
color: ${cssManager.bdTheme('#000000', '#ffffff')};

DON'T: Create Components Without Demos

// ❌ WRONG
export class DeesComponent extends DeesElement {
  // No demo property
}

// ✅ CORRECT
export class DeesComponent extends DeesElement {
  public static demo = demoFunc.demoFunc;
}

DON'T: Emit Non-Bubbling Events

// ❌ WRONG
this.dispatchEvent(new CustomEvent('change', {
  detail: this.value
}));

// ✅ CORRECT
this.dispatchEvent(new CustomEvent('change', {
  detail: this.value,
  bubbles: true,
  composed: true
}));

DON'T: Skip Cleanup

// ❌ WRONG
public connectedCallback() {
  window.addEventListener('resize', this.handleResize);
}

// ✅ CORRECT
public connectedCallback() {
  super.connectedCallback();
  window.addEventListener('resize', this.handleResize);
}

public disconnectedCallback() {
  super.disconnectedCallback();
  window.removeEventListener('resize', this.handleResize);
}

DON'T: Use Inline Styles for Theming

// ❌ WRONG
<div style="background-color: ${this.darkMode ? '#000' : '#fff'}">

// ✅ CORRECT
<div class="themed-container">
// In styles:
.themed-container {
  background-color: ${cssManager.bdTheme('#ffffff', '#000000')};
}

DON'T: Forget Mobile Responsiveness

// ❌ WRONG
:host {
  width: 800px; // Fixed width
}

// ✅ CORRECT
:host {
  width: 100%;
  max-width: 800px;
}

@media (max-width: 768px) {
  :host {
    /* Mobile adjustments */
  }
}

Code Examples

Example: Creating a New Button Variant

// dees-special-button.ts
import { customElement, property, css, html } from '@design.estate/dees-element';
import { DeesElement } from '@design.estate/dees-element';
import * as cssManager from './00colors.js';
import * as demoFunc from './dees-special-button.demo.js';

@customElement('dees-special-button')
export class DeesSpecialButton extends DeesElement {
  public static demo = demoFunc.demoFunc;

  @property({ type: String })
  public text: string = 'Click me';

  @property({ type: Boolean, reflect: true })
  public loading: boolean = false;

  public static styles = [
    cssManager.defaultStyles,
    css`
      :host {
        display: inline-block;
      }

      .button {
        padding: 8px 16px;
        background: ${cssManager.bdTheme('#0066ff', '#0044cc')};
        color: white;
        border: none;
        border-radius: 4px;
        cursor: pointer;
        transition: all 0.2s;
      }

      .button:hover {
        transform: translateY(-2px);
        box-shadow: 0 4px 8px ${cssManager.bdTheme('rgba(0,0,0,0.1)', 'rgba(0,0,0,0.3)')};
      }

      :host([loading]) .button {
        opacity: 0.7;
        cursor: not-allowed;
      }
    `
  ];

  public render() {
    return html`
      <button class="button" ?disabled=${this.loading} @click=${this.handleClick}>
        ${this.loading ? html`<dees-spinner size="small"></dees-spinner>` : this.text}
      </button>
    `;
  }

  private handleClick() {
    this.dispatchEvent(new CustomEvent('special-click', {
      bubbles: true,
      composed: true
    }));
  }
}

Example: Creating a Form Input

// dees-input-special.ts
export class DeesInputSpecial extends DeesInputBase<string> {
  public static demo = demoFunc.demoFunc;

  public render() {
    return html`
      <dees-label .label=${this.label} .required=${this.required}>
        <input
          type="text"
          .value=${this.value || ''}
          ?disabled=${this.disabled}
          @input=${this.handleInput}
          @blur=${this.handleBlur}
        />
      </dees-label>
    `;
  }

  private handleInput(e: Event) {
    this.value = (e.target as HTMLInputElement).value;
    this.changeSubject.next(this);
  }

  private handleBlur() {
    this.dispatchEvent(new CustomEvent('blur', {
      bubbles: true,
      composed: true
    }));
  }

  public getValue(): string {
    return this.value;
  }

  public setValue(value: string): void {
    this.value = value;
    this.changeSubject.next(this);
  }
}

Summary

This playbook represents the collective wisdom and patterns found in the @design.estate/dees-catalog component library. Following these guidelines will help you create components that are:

  • Consistent: Following established patterns
  • Maintainable: Easy to understand and modify
  • Performant: Optimized for real-world use
  • Accessible: Usable by everyone
  • Theme-aware: Supporting light and dark modes
  • Well-integrated: Working seamlessly with the component ecosystem

Remember: When in doubt, look at existing components for examples. The codebase itself is the best documentation of these patterns in action.