Skip to content

meefik/neux

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

68 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

{\} NEUX

NEUX is a nifty ecosystem for user experience development. It is a JavaScript frontend micro-library with reactivity states and views. The library has features and tools are suitable for building single-page applications (SPA) or isolated components.

Here are the main concepts behind NEUX:

  • Minimum interaction with the library during development, more native JS code.
  • Instead of HTML templates and JSX, defining views as nested JS objects with a set of attributes that are completely equivalent to the attributes of native HTML elements.
  • Support for modern two-way reactivity.
  • Availability of standard components to implement the basic SPA functionality:
    • routing,
    • localization,
    • synchronization of states with persistent storage,
    • calling remote procedures on the backend.
  • Small library size ~ 11kb (5kb gzipped).
  • It is open source software under MIT license.

Content

  1. Installation
  2. States
  3. Views
  4. Localization
  5. Routing
  6. Remote procedure call
  7. State synchronization
  8. Use with Vite
  9. Use with Tailwind CSS
  10. Use with Web Components
  11. Create your own Web Component
  12. Examples

Installation

When using bundlers, you need to install the library from NPM:

npm install neux

And import it into your project:

import { 
  createState,
  createView,
  createL10n,
  createRouter,
  createSync,
  createRPC
} from 'neux'
// use the library here...

Also, you can use it from the browser:

<script src="https://unpkg.com/neux"></script>
<script>
  const { 
    createState,
    createView,
    createL10n,
    createRouter,
    createSync,
    createRPC
  } = NEUX;
  // use the library here...
</script>

States

The state is a proxy for objects. States are used to track changes and distribute them to related views and other state fields.

An example with comments:

const state = createState({
  // regular fields
  counter: 1,
  multiplier: 2,
  // the field as array
  list: [
    { text: 'Item 1' },
    { text: 'Item 2', checked: true }
  ],
  // the computed field for an object
  double: (obj, prop) => obj.$counter * 2,
  // the computed field for an array
  filtered: (obj, prop) => {
    return obj.$list.filter(item => item.checked);
  }
});
// set or change the computed field
state.double = (obj, prop) => state.$counter * state.$multiplier;
// change the regular field
state.counter++;
state.list.push({ text: 'Item 3' });
// remove the field with all listeners
delete state.double;

The $ character ahead of a field name is used in computed fields to observe its changes. When changes occur in this field, this function will automatically recalled and receives the new value of the computed field.

Attention!

  1. When deleting or replacing the tracking object/array in the computed field, all binding is lost.
  2. In computed fields, binding occurs only with those fields that are called during the first synchronous execution.

Listening for state changes:

const handler = (newv, oldv, prop, obj) => {
  console.log(newv, oldv, prop, obj);
  if (newv === undefined) {
    console.log('deleted');
  } else if (oldv === undefined) {
    console.log('added');
  } else {
    console.log('updated');
  }
};
// add a specified listener
state.$$on('double', handler);
// add a specified listener that only calls once
state.$$once('double', handler);
// remove a specified listener
state.$$off('double', handler);
// remove all listeners for the specified field
state.$$off('double');
// add a listener to observe any changes
// on this object and all children
state.$$on('*', handler);

Views

A view is a declarative definition of DOM elements.

An example with comments:

const state = createState({
  list: [
    { text: 'Item 1'},
    { text: 'Item 2', checked: true },
    { text: 'Item 3' }
  ]
});
const view = createView({
  children: [{
    tagName: 'h1',
    textContent: 'To Do'
  }, {
    tagName: 'input',
    placeholder: 'Enter your task...',
    autofocus: true,
    on: {
      keyup() {
        return (e) => {
          if (e.keyCode === 13) {
            e.preventDefault();
            state.list.push({ text: e.target.value });
            e.target.value = '';
          }
        };
      }
    }
  }, {
    tagName: 'div',
    children: [{
      tagName: 'input',
      type: 'checkbox',
      on: {
        change() {
          return (e) => {
            const checked = e.target.checked;
            state.list.forEach((item) => {
              item.checked = checked;
            });
          };
        }
      }
    }, {
      tagName: 'label',
      textContent: 'Mark all as complete'
    }]
  }, {
    tagName: 'ul',
    children: () => {
      // redraw the list if any child element is added, replaced or removed
      // any updates inside children are ignored
      return state.list.$$each(item => {
        return {
          tagName: 'li',
          on: {
            mounted() {
              return () => console.log('mounted', item);
            },
            removed() {
              return () => console.log('removed', item);
            }
          },
          children: [{
            tagName: 'input',
            type: 'checkbox',
            checked: () => item.$checked,
            on: {
              change() {
                return (e) => {
                  item.checked = e.target.checked;
                };
              }
            }
          }, {
            tagName: 'label',
            textContent: () => item.$text
          }, {
            tagName: 'a',
            href: '#',
            textContent: '[x]',
            on: {
              click() {
                return (e) => {
                  e.preventDefault();
                  const index = state.list.indexOf(item);
                  state.list.splice(index, 1);
                };
              }
            }
          }]
        };
      });
    }
  }, {
    textContent: () => `Total items: ${state.list.$length}`
  }]
}, { target: document.body });

Additional events for each element:

  • mounted - the element was mounted in the DOM;
  • removed - the element was removed from the DOM;
  • changed - the element attribute was changed.

You can change the DOM using simple operations on objects and arrays:

const view = createView({
  tagName: 'ul',
  children: [{
    tagName: 'li',
    textContent: 'Item 1'
  }]
}, { target: document.body });

view.children.push({
  tagName: 'li',
  textContent: 'Item 2'
});
view.children[1].textContent = 'Item 3';
view.children.shift();

You can pass HTML markup or the entire HTML element in the "node" parameter:

createView({
  children: [{
    // create element from HTML markup
    node: '<main><p>My content</p></main>',
    style: {
      color: 'red'
    }
  }, {
    // create element from HTMLElement
    node: document.createElement('footer'),
    textContent: 'Powered by NEUX'
  }]
}, { target: document.body });

You can include any SVG icon as HTML markup and change its styles (size, color) via the classList or attributes field:

import githubIcon from '@svg-icons/fa-brands/github.svg?raw';

createView({
  node: githubIcon,
  classList: ['icon'],
  attributes: {
    width: '64px',
    height: '64px'
  }
}, { target: document.body });

Localization

Localization is used to display the application interface in different languages.

Translation example:

const l10n = createL10n({
  en: {
    say: {
      hello: "Hello %{name}!"
    }
  },
  ru: {
    say: {
      hello: "Привет %{name}!"
    }
  }
}, {
  fallback: 'en',
  lang: navigator.language
});
l10n.lang = 'en';
const msgEn = l10n.t('say.hello', { name: 'World' });
console.log(msgEn); // Hello World!
l10n.lang = 'ru';
const msgRu = l10n.t('say.hello', { name: 'Мир' });
console.log(msgRu); // Привет Мир!
const msg = l10n.t('say.hello', { name: 'World' }, 'en');
console.log(msg); // Hello World!

Convert date and time to localized string:

const l10n = createL10n({
  en: {
    month: {
      full: ['January','February','March','April','May','June','July','August','September','October','November','December'],
      short: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
    },
    day:{
      full: ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'],
      short: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']
    },
    time: {
      am: ['am','AM'],
      pm: ['pm','PM']
    }
  }
}, {
  fallback: 'en',
  lang: navigator.language
});
// date format
const date = new Date('2024-03-15T14:30:00.000Z');
const text = l10n.d(date, '{dddd}, {DD} {MMM} {YYYY} {h}:{mm} {A} {Z}');
console.log(text); // Friday, 15 Mar 2024 2:30 PM +0:00

Description of date format similar to Moment.js

Input Example Description
YYYY 2024 4 digit year
YY 24 2 digit year
Q 1..4 Quarter of year
M MM 1..12 Month number
MMM MMMM Jan..December Month name
D DD 1..31 Day of month
DDD DDDD 1..365 Day of year
ddd dddd Mon...Sunday Day name
X 1712751431.381 Unix timestamp
x 1712751431381 Unix ms timestamp
H HH 0..23 Hours (24 hour time)
h hh 1..12 Hours (12 hour time used with a A.)
k kk 1..24 Hours (24 hour time from 1 to 24)
a A am PM Post or ante meridiem
m mm 0..59 Minutes
s ss 0..59 Seconds
Z ZZ +12:00 Offset from UTC

Routing

Routing is used to link separate states or pages of a web application to the address bar in the browser.

An example with comments:

const router = createRouter({
  // #/:section/:page
  section: /^\/(\w+)\/\w+$/,
  page: /^\/\w+\/(\w+)$/
}, {
  home: '/section1/page1'
});
createView({
  children: [{
    children: [{
      tagName: 'a',
      href: '#/section1/page1',
      textContent: 'Page 1'
    }, {
      tagName: 'a',
      href: '#/section1/page2?key1=1',
      textContent: 'Page 2'
    }, {
      tagName: 'button',
      textContent: 'Page 3',
      on: {
        click() {
          return () => {
            router.navigate('/section1/page3', { key1: '1', key2: '2' });
          };
        }
      }
    }]
  }, {
    children: () => {
      switch (router.params.$page) {
      case 'page1':
        return [{
          tagName: 'p',
          textContent: 'Page 1'
        }];
      case 'page2':
        return [{
          tagName: 'p',
          textContent: 'Page 2'
        }];
      case 'page3':
        return [{
          tagName: 'p',
          textContent: 'Page 3'
        }];
      default:
        return [{
          tagName: 'p',
          textContent: 'Not found'
        }];
      }
    }
  }, {
    tagName: 'pre',
    textContent: () => [
      `Path: ${router.$path}`,
      `Params: ${JSON.stringify(router.params)}`,
      `Query: ${JSON.stringify(router.query)}`
    ].join('\n')
  }]
}, { target: document.body });

Remote procedure call

RPC is short for Remote Procedure Call. This abstraction allows you to execute code on the backend by calling normal functions on the frontend.

Here is an example of calling some function:

// create RPC client
const rpc = createRPC({ url: '/api/rpc' });
// define input parameters
const text = 'Text'; // as text
const object = { text }; // as object
const blob = new Blob([text]); // as blob
const file = new File([blob], 'file.txt'); // as file
const formData = new FormData(); // as form-data
formData.append('file', file);
// call the remote function named "hello"
const response = await rpc.hello(/* params */);
console.log(response);

The function can accept input parameters in the formats String, Object, Blob, File or FormData. The function response can be one of three types String, Object or Blob.

The default backend request HTTP method is POST. The API address on the backend has the format /api/rpc/:method, where :method is the name of the function to run.

The request can be of the following types:

  • application/json - format for passing JavaScript objects.
  • multipart/from-data - file transfer format.
  • text/plain - all non-objects are passed as text.

The response must be of the following types:

  • application/json - format for passing JavaScript objects.
  • application/octet-stream - file transfer format.
  • text/plain - all non-objects are passed as text.

Below is an example of using RPC for some imaginary backend:

let token = '';
// create RPC client
const rpc = createRPC({
  // RPC backend endpoint
  url: '/api/rpc',
  // include headers for every request
  headers: {
    // getter for authorization header
    get Authorization() {
      return token && `Bearer ${token}`;
    }
  },
  // include cookies for every request
  // credentials: 'include',
  // enable CORS for requests
  // mode: 'cors'
});
// authorize and get the session token
token = await rpc.login({ username, password });
// upload file from <input id="file" type="file" />
const file = document.getElementById('file').files[0];
const { id, name, type, size } = await rpc.upload(file);
// send json data
const res = await rpc.addComment({
  author: 'John Doe',
  text: 'Hello World!',
  time: new Date(),
  attachments: [id]
});
// update data
await rpc.updateComment({
  id: res.id,
  text: 'Edited message'
});
// receive json data
const comment = await rpc.getComment({
  id: res.id
});

Below is an example implementation of the server API for RPC on Node.js:

// server.js
import http from 'node:http';
import os from 'node:os';
import fs from 'node:fs';
import { Readable } from 'node:stream';
import express from 'express';
import multer from 'multer';

const { PORT = 3000, HOST = '127.0.0.1' } = process.env;
const upload = multer({ dest: os.tmpdir() });
const app = express();

app.enable('trust proxy');
app.use(express.urlencoded({ extended: true }));
app.use(express.text());
app.use(express.json());

const todos = [];
const handlers = {
  async hello (name) {
    return `Hello ${name}!`;
  },
  async time () {
    return { time: new Date() };
  },
  async upload (file) {
    return file;
  },
  async download (path) {
    return fs.createReadStream(path);
  }
};

app.post('/api/rpc/:method',
  upload.single('file'),
  async function (req, res, next) {
    try {
      const { method } = req.params;
      const params = req.file || req.body;
        const handler = handlers[method];
        if (typeof handler !== 'function') {
          throw Error('Method not found');
        }
        const data = await handler(params);
      if (data instanceof Readable) {
        res.set('Content-Type', 'application/octet-stream');
        data.on('error', () => res.end());
        return data.pipe(res);
      }
      if (typeof data === 'object') {
        res.set('Content-Type', 'application/json');
        return res.json(data);
      }
      res.set('Content-Type', 'text/plain');
      res.send(data);
    } catch (err) {
      next(err);
    }
  });

http.Server(app).listen(PORT, HOST);

You can start the server like this:

npm install express multer
node server.js

And an example of calling RPC methods in NEUX:

const rpc = createRPC({ url: '/api/rpc' })
rpc.hello('World').then(data => {
  console.log('hello:', data);
});
rpc.time().then(data => {
  console.log('time:', data);
});
rpc.download('/tmp/test').then(data => {
  console.log('download:', data);
});
const blob = new Blob(['Hello World!']);
const file = new File([blob], 'demo.txt');
rpc.upload(file).then(data => {
  console.log('upload:', data);
});

State synchronization

State synchronization is used to save their data to persistent storage.

Synchronizing state with localStorage:

const state = createState({
  list: []
});
// describe the synchronization function
const syncer = (newv, oldv) => {
  if (!oldv) {
    return JSON.parse(localStorage.getItem('todos') || '[]');
  } else {
    localStorage.setItem('todos', JSON.stringify(newv));
  }
  return newv;
};
// create a synchronization with state
// slippage (in ms) helps group and reduce call frequency
const sync = createSync(state.list, syncer, { slippage: 100 });
// sync state with local storage
sync();

Synchronizing state with remote store:

const state = createState({
  list: []
});
// describe the synchronization function
const syncer = async (newv, oldv) => {
  return await rpc.getTodoList();
};
// create a synchronization with state
const sync = createSync(state.list, syncer);
// sync state with remote store
sync();

Undo last changes or clear:

const state = createState({
  list: [
    { text: 'Item 1', checked: false }
  ]
});
// describe the synchronization function
const syncer = (newv, oldv, action) => {
  if (action === 'undo') return oldv;
  if (action === 'clear') return [];
  return newv;
};
// create a synchronization with state
const sync = createSync(state.list, syncer);
// commit current state
sync();
// change state
state.list[0].checked = true;
// commit changes
sync();
// change state again
state.list[0].checked = false;
// undo last change
sync('undo');
// delete all data
sync('clear');

Use with Vite

You can use NEUX with Vite bundler.

How to set up:

1. Create a new Vite project (select a variant JavaScript):

npm create vite@latest

2. Install the neux module:

npm install --save-dev neux

3. Paste your application code into the main.js file:

import { createView } from 'neux';

createView({
  textContent: 'Hello World!'
}, { target: document.body });

4. Run the project:

npm run dev

Use with Tailwind CSS

It also fits well with Tailwind CSS. After installing Tailwind CSS into your project you can use CSS classes in the classList field as String or Array.

How to set up your Vite project:

1. Install the required modules:

npm install --save-dev tailwindcss postcss autoprefixer

2. Create the file tailwind.config.js:

export default {
  content: ['./index.html', './main.js'],
  theme: {
    extend: {}
  },
  plugins: []
};

3. Create the file postcss.config.js:

export default {
  plugins: {
    tailwindcss: {},
    autoprefixer: {}
  }
};

4. Replace the contents of the style.css file with:

@tailwind base;
@tailwind components;
@tailwind utilities;

5. Replace the contents of the main.js file with:

import './style.css';
import { createView } from 'neux';

createView({
  classList: ['container', 'mx-auto', 'py-5'],
  children: [{
    classList: ['bg-white', 'shadow', 'sm:rounded-lg'],
    children: [{
      classList: ['px-4', 'py-5', 'sm:p-6'],
      children: [{
        tagName: 'h3',
        classList: ['text-base', 'font-semibold', 'leading-6', 'text-gray-900'],
        textContent: 'Welcome to NEUX'
      }, {
        classList: ['mt-2', 'max-w-xl', 'text-sm', 'text-gray-500'],
        children: [{
          tagName: 'p',
          textContent: 'It is a JavaScript frontend micro-library with reactivity states and views.'
        }]
      }, {
        classList: ['mt-5'],
        children: [{
          tagName: 'button',
          type: 'button',
          classList: ['inline-flex', 'items-center', 'rounded-md', 'bg-indigo-600', 'px-3', 'py-2', 
            'text-sm', 'font-semibold', 'text-white', 'shadow-sm', 'hover:bg-indigo-500', 
            'focus-visible:outline', 'focus-visible:outline-2', 'focus-visible:outline-offset-2',
            'focus-visible:outline-indigo-500'],
          textContent: 'See on GitHub',
          on: {
            click() {
              return () => {
                window.open('https://meefik.github.io/neux');
              };
            }
          }
        }]
      }]
    }]
  }]
}, { target: document.body });

Use with Web Components

You can use NEUX along with any Web Components. Many component libraries can be found here. You can create your own components using one of the libraries, for example Lit.

Let's say you have a Web Component that looks like this:

<my-component attr1="one" attr-two="two" onMyEvent="alert('Action')">
  <my-nested-component>Hello</my-nested-component>
</my-component>

In NEUX, it would be the following definition:

createView({
  tagName: 'my-component',
  attr1: 'one',
  attrTwo: 'two',
  on: {
    MyEvent() {
      return () => alert('Action');
    }
  },
  children: [{
    tagName: 'my-nested-component',
    textContent: 'Hello'
  }]
}, { target: document.body });

Create your own Web Component

An example of a web component definition:

class Counter extends HTMLElement {
  static get observedAttributes() {
    return ['value'];
  }
  constructor() {
    super();
    const context = {};
    this.attrs = createState({}, context);
    this.attrs.$$on('*', (newv, oldv, prop) => {
      this.setAttribute(prop, newv);
    });
    const target = this.attachShadow({ mode: 'open' });
    this.view = createView(this.template(), { context, target });
  }
  attributeChangedCallback(name, oldv, newv) {
    this.attrs[name] = newv;
  }
  template() {
    return {
      children: [{
        tagName: 'input',
        type: 'number',
        value: () => this.attrs.$value,
        on: {
          change() {
            return (e) => {
              this.attrs.value = e.target.value;
            };
          }
        }
      }, {
        tagName: 'slot',
        name: 'label',
        textContent: () => this.attrs.$value
      }]
    };
  }
}
customElements.define('ne-counter', Counter);

Use this web component:

const state = createState({
  counter: 1
});
createView({
  tagName: 'ne-counter',
  attributes: {
    value: () => state.$counter,
  },
  on: {
    changed() {
      return (e) => {
        state.counter = parseInt(e.detail.newValue);
      };
    }
  },
  children: [{
    tagName: 'span',
    slot: 'label',
    textContent: () => state.$counter
  }]
}, { target: document.body });

Examples

You can find development examples with NEUX in the following repositories:

  • neux-todo-app - example To-Do application on NEUX + Tailwind CSS + Vite;
  • neux-ionic-app - example To-Do application on NEUX + Ionic Web Components + Vite;
  • neux-demo - various examples on NEUX.

About

A frontend micro-library with reactivity states and views

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published