-
Notifications
You must be signed in to change notification settings - Fork 2
Overview of UI Framework
--- under development ---
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.
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 theapp-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.
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
<!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>
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
})();
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 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:
<!-- #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
anddata-viewjs
attributes to sideload CSS from the same directory inapp-static/views
, and additional bundles in in thepublic/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 mainapp.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.
This is an example implementation of the <nc-login>
component used in the #netcreate
html fragment above.
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.
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:
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'));
})();