Extension

Extension demo built with Expressive MVC - the complete source below runs as an editable sandbox when JavaScript is enabled.

App.tsx

import './App.css';

import { Component } from '@expressive/react';
import type { ReactNode } from 'react';

export default () => (
  <div className="container">
    <h1>Render Composition</h1>
    <p>
      A subclass that writes its own <code>render</code> does not replace the
      base's - it composes with it. Panel's render runs outermost and the
      subclass's output arrives as <code>props.children</code>. There is no{' '}
      <code>super.render()</code> to call; you read children where content should
      slot.
    </p>
    <div className="pair">
      <Tally />
      <Notes />
    </div>
    <small>
      Click either header. The chrome, the toggle, and the <code>open</code> field
      behind it all belong to Panel - each subclass wrote only what sits inside,
      and each instance keeps its own state. Collapse one and the base is visibly
      still running the other.
    </small>
  </div>
);

class Panel extends Component {
  title = 'Panel';
  open = true;

  toggle() {
    this.open = !this.open;
  }

  render(props = {} as { children?: ReactNode }) {
    const { title, open } = this;

    return (
      <section className="panel">
        <button className="head" aria-expanded={open} onClick={() => this.toggle()}>
          <b>{title}</b>
          <span>{open ? '–' : '+'}</span>
        </button>
        {open && <div className="body">{props.children}</div>}
      </section>
    );
  }
}

class Tally extends Panel {
  title = 'Tally';
  count = 3;

  render() {
    const { count } = this;

    return (
      <div className="tally">
        <button onClick={() => this.count--}>−</button>
        <output>{count}</output>
        <button onClick={() => this.count++}>+</button>
      </div>
    );
  }
}

class Notes extends Panel {
  title = 'Notes';
  text = '';

  render() {
    const { text } = this;

    return (
      <textarea
        rows={2}
        value={text}
        placeholder="Type something, then collapse…"
        onChange={(e) => (this.text = e.target.value)}
      />
    );
  }
}

App.css

.panel {
  display: flex;
  flex: 1 1 12rem;
  flex-direction: column;
  align-self: flex-start;
  overflow: hidden;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
}

.panel .head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s2);
  padding: var(--s2) var(--s3);
  color: var(--fg);
  background: var(--accent-soft);
  border: none;
  border-radius: 0;
  text-align: left;
}

.panel .head:hover {
  background: var(--accent-soft);
  border: none;
}

.panel .head span {
  font-family: var(--font-mono);
  color: var(--accent);
}

.panel .body {
  display: flex;
  padding: var(--s3);
}

.tally {
  display: flex;
  align-items: center;
  gap: var(--s3);
}

.tally output {
  min-width: 2rem;
  font-family: var(--font-mono);
  font-size: var(--t-lg);
  color: var(--accent);
}