Skip to content

App shell

Three contexts, provided once, used anywhere below.

The root provides a session, a theme and a notifier. The user menu, the theme switch and any page inject what they need, with no props passed through the layers between.

Preview

Open the page on its own

Code

Save it as an .html file and open it in a browser. It loads Signy and Iris CSS from the CDN, so there is nothing to install or build.

Show the full page
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="color-scheme" content="light dark">
    <title>App shell - Signy example</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/iriscss@1/dist/iris.min.css">
    <script src="https://cdn.jsdelivr.net/npm/signy@1/dist/signy.global.min.js"></script>
  </head>
  <body>
    <div id="app"></div>

    <script>
      const {
        Component, Signal, application, html, effect, For, Show,
        createContext, createRequiredContext,
      } = Signy;

      // Required: a component that injects them with no provider above it throws, so a
      // missing provider shows up at once instead of as a silent undefined
      const SessionContext = createRequiredContext("Session");
      const NotifierContext = createRequiredContext("Notifier");
      // Optional: light unless a provider says otherwise
      const ThemeContext = createContext(new Signal("light"), "Theme");

      let toastId = 1;

      function createNotifier() {
        const toasts = new Signal([]);
        const dismiss = (id) => toasts.set((list) => list.filter((toast) => toast.id !== id));
        const notify = (message, kind = "success") => {
          const id = toastId++;
          toasts.set((list) => [...list, { id, message, kind }]);
          setTimeout(() => dismiss(id), 4000);
        };
        return { toasts, notify, dismiss };
      }

      class UserMenu extends Component {
        render() {
          const session = SessionContext.inject(this);
          const { notify } = NotifierContext.inject(this);
          return html`
            ${Show({
              when: () => session.value,
              fallback: html`
                <button class="small" onClick=${() => {
                  session.value = { name: "Ada Lovelace" };
                  notify("Signed in as Ada.");
                }}>Sign in</button>
              `,
              children: (user) => html`
                <details class="dropdown">
                  <summary>${user.name}</summary>
                  <ul>
                    <li><a href="#profile">Profile</a></li>
                    <li><button onClick=${() => {
                      session.value = null;
                      notify("Signed out.", "info");
                    }}>Sign out</button></li>
                  </ul>
                </details>
              `,
            })}
          `;
        }
      }

      class ThemeSwitch extends Component {
        render() {
          const theme = ThemeContext.inject(this);
          return html`
            <label>
              <input type="checkbox" role="switch" onChange=${(e) => (theme.value = e.currentTarget.checked ? "dark" : "light")} />
              Dark
            </label>
          `;
        }
      }

      class Editor extends Component {
        render() {
          const session = SessionContext.inject(this);
          const { notify } = NotifierContext.inject(this);
          return html`
            <h1>${() => (session.value ? `Hello, ${session.value.name.split(" ")[0]}` : "Hello, guest")}</h1>
            <p>Nothing on this page passes props down. Each part injects what it needs.</p>
            <div class="cluster">
              <button class="primary" onClick=${() => notify("Draft saved.")}>Save draft</button>
              <button class="danger outlined" onClick=${() => notify("The draft was deleted.", "danger")}>Delete draft</button>
            </div>
          `;
        }
      }

      class Toasts extends Component {
        render() {
          const { toasts, dismiss } = NotifierContext.inject(this);
          return html`
            <div class="toasts" aria-live="polite">
              ${For({
                each: () => toasts.value,
                keyBy: "id",
                children: (toast) => html`
                  <div class=${`toast ${toast.kind}`} role="status">
                    <p>${toast.message}</p>
                    <button class="close" aria-label="Dismiss" onClick=${() => dismiss(toast.id)}></button>
                  </div>
                `,
              })}
            </div>
          `;
        }
      }

      class App extends Component {
        session = new Signal(null);
        theme = new Signal("light");
        notifier = createNotifier();

        setup() {
          SessionContext.provide(this, this.session);
          ThemeContext.provide(this, this.theme);
          NotifierContext.provide(this, this.notifier);
          effect(() => {
            document.documentElement.dataset.theme = this.theme.value;
          });
        }

        render() {
          return html`
            <header class="navbar">
              <nav class="container" aria-label="Main">
                <ul><li><strong>Writer</strong></li></ul>
                <ul>
                  <li><${ThemeSwitch} /></li>
                  <li><${UserMenu} /></li>
                </ul>
              </nav>
            </header>
            <main class="container"><${Editor} /></main>
            <${Toasts} />
          `;
        }
      }

      application(App).mount("#app");
    </script>
  </body>
</html>

What it uses