Skip to content

Overview of UI Framework

DSri Seah edited this page Feb 9, 2025 · 21 revisions

--- under development ---

Introduction

Historically URSYS has been framework-agnostic, as it was developed to work around React shortcomings. The new URSYS UI System is called VIEWLIB, and is an experimental vanilla HTML/JS/CSS implementation of a framework that works more closely with our data and module architecture.

Design

VIEWLIB is built on top of SNA App, which uses a structured directory containing various entryfiles that are automatically linked, built, or loaded. Creating a webapp based on SNA through VIEWLIB consists of:

  • add a view myview.html to the app-static/views directory as pure vanilla HTML.
  • navigate to #myview in a web browser to dynamically load the view.
  • optionally specify an associated CSS file and additional JS bundles for the view, which are loaded when the route is changed.

The views are based on html fragments that are dynamically fetched and swapped-into an anchoring element in the main document. These are pure html with no template extensions. The design intent is that views are intended only for layout; data binding and event handling is handled by custom web components that package the complexity in its shadow DOM and rely on top-level system modules to do work.

Additional <script> tags can be added to the html fragment for runtime binding of web component elements to top-level system modules, using querySelector() to fetch the element and invoking custom methods on it.

Runtime Logic

In an SNA App, there are two runtime entry points: the <script> tag in the index.html file that loads the js/app.js bundle.

    app-source/                SNA source dir for code generation
      app.ts                   source for public/js/app.js bundle
      viewlib/                 .. prototype of VIEWLIB system
        router.ts              .. hashrouter used in app.ts
        webc/                  .. web components directory
          auto-webc-imports.ts .. auto-generated registration module
          nc-login.ts          .. custom HTMLElement mapped to <nc-login>
    app-static/                SNA assets copied as-is to public
>>>   index.html               .. define page layout, load js bundle
      css/                     .. css files
      views/                   .. views loaded by hashrouter
        myview.html            .. html fragment routed to #myview
        myview.css             .. css file loaded if attrib data-viewcss is set 
    public/                    SNA webserver files
      views/                   .. copied from app-static as-is
      js/                      .. javascript assets
>>>     app.js                 .. esbuild bundle loaded by index.js to init app
      index.html               .. copied from index.html as-is

minimal index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <link rel="stylesheet" href="css/styles.css">
  <script src="js/app.js" type="module" defer></script>
</head>

<body>
  <!-- Routed Views -->
  <div id="shell-view"></div>
</body>

</html>

minimal app.ts

This is transpiled by esbuild to js/app.js and loaded by index.html using a deferred script. Deferred scripts are parsed asynchronously as fast as possible, but invoked only after the DOM has completed rendered. This is important for both ensuring non-blocking asset loads and ensuring that our application has a stable DOM before any dependent code runs.

import { DeclareComponents, AttachRouter } from './viewlib';
import { SNA } from '@ursys/core';
import * as UR from '@ursys/core';

globalThis.UR = UR; // make UR available to html fragments

(async () => {
  DeclareComponents(); // defines custom tags for web components
  AttachRouter(document.getElementById('shell-view'));
  await SNA.Start(); // starts the SNA application lifecycle
})();

Routing

Navigating to a hash will load an html fragment from the correspondingly named view file (an html fragment) in public/views/, loading it dynamically and replacing the shell-view container with its contents. This preserves the running state of the Javascript engine between route changes.

Example hash routes:

  • localhost/#netcreate
  • localhost:8080/#netcreate

The router fires on two window events:

  • window.addEventListener('hashchange', m_RouteHash);
  • `window.addEventListener('load', m_RouteHash);

See the source in app-source/viewlib/router.ts to see how it uses the data-* attributes to sideload additional CSS and JS files.

Views

Views are used to define application layout of key areas and components, so it's easy to see the structure of the main system areas.

Tip

Complexity is pushed into into custom web components that (hopefully) follow URSYS code practices of distinguishing between data, derived data, ui state, app state, operations, and user actions. Additionally, components should follow the reactive pattern for updates when a data operation is performed instead of optimistically assuming that the change worked.

Technically, views are html fragments that have a root div with optional data attributes that the router uses to sideload additional assets.

Here is an example:

minimal netcreate.html view

<!-- #netcreate view -->
<div id="netcreate" data-viewcss="netcreate" data-viewjs="nc-bundle">
  <div class="header-row">
    <div class="header-left">NetCreateLogo</div>
    <nc-login></nc-login>
  </div>
</div>

<!-- optional script for setup -->
<script>
  if (UR) {
    const PR = UR.ConsoleStyler('fragment', 'TagPink');
    console.log(...PR('UR is defined!!!'));
  }
  const login = document.querySelector('nc-login');
  const validatorModule = {
    decodeToken: token => {}
  };
  login.validator = validatorModule;
</script>

NOTES:

  • The html fragment can make use of web components that are declared in the app-source/viewlib/webc directory.
  • The html fragment has a root element that uses data-viewcss and data-viewjs attributes to sideload CSS from the same directory in app-static/views, and additional bundles in in the public/js directory at runtime.
  • The html fragment can also include <script> tags for per-view javascript initialization. These scripts can access URSYS modules through globalThis.UR and any other modules that are so-defined in the main app.js bundle.

The scripting environment in the html fragments is used for attaching runtime props to Web Components for data binding purposes. This isn't ideal, but the VIEWLIB system tries to stay pure vanilla without needing runtime transformations.

Web Components

This is an example implementation of the <nc-login> component used in the #netcreate html fragment above.

minimal nc-login.ts webcomponent

class NCLoginControl extends HTMLElement {
  validator: Validator;

  constructor() {
    super();
    // create shadow DOM
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
    <div class="--NCLoginControl">
      <span>type your login token</span>
      <input name="sessionToken" type="text" placeholder="CLASSID-PROJID-CODE" />
      <button>LOGIN</button>
    </div>
    `;
    // attach event listeners
    const input = shadow.querySelector('input');
    input.addEventListener('input', this.onInput.bind(this));
    const button = shadow.querySelector('button');
    button.addEventListener('click', this.onSubmit.bind(this));
    // add styling
    const style = document.createElement('style');
    style.textContent = ``;
    shadow.appendChild(style);
    // is UR available?
    if (globalThis.UR) {
      console.log('UR is available');
    }
  }

  /** handle change events locally for input control
   *  calls validator if one is set
   */
  onInput(e: Event) {
    const input = e.target as HTMLInputElement;
    const token = input.value;
    if (this.validator) {
      console.log('validating token');
      const data = this.validator.decodeToken(token);
      const hint = data.error ? data.hint : 'valid token';
      input.title = hint;
    }
  }

  /** handle data submission */
  onSubmit() { ... }
}

This example is largely for showing how the event and props are set up, and doesn't make full use of the features of Web Component. I made a web component cheatsheet that distills my initial understanding of how it works.

Adding a Custom Web Component to VIEWLIB

First create a file that exports a subclasser of HTMLElement. Put this file in app-source/viewlib/webc and name it as you want the custom tag to be declared:

minimal web component boilerplate

class MyCustomElement extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = ``;
    const style = document.createElement('style');
    style.textContent = ``;
    shadow.appendChild(style);
  }
}
export default MyCustomElement;

The name you pick for the file will be used to define the custom tag. For example:

  • filename: my-component.ts
  • defined as <my-component>

The filename has to follow the valid custom element name spec; in short, the pattern is to use two lowercase strings with a hyphen between them.

All top-level files declared in the app-source/viewlib/webc directory that match the pattern will be defined in the auto-generated file auto-webc-imports.ts. Here's what it looks like:

// sna webcomponent autogenerated file

import NC_LOGIN from './nc-login.ts';

function DeclareComponents() {
  customElements.define('nc-login', NC_LOGIN);
}

export { DeclareComponents };

To make the web components available for use, you must invoke theDeclareComponents() method which is exposed by VIEWLIB. See app-source/app.ts for an example:

import { DeclareComponents, AttachRouter } from './viewlib';

(async () => {
  DeclareComponents();
  AttachRouter(document.getElementById('shell-view'));
})();