mogera551 / quel

more declarative javascript front-end-framework
MIT License
1 stars 0 forks source link
declarative-programming declarative-ui dot-notation easy-to-use front-end-framework javascript mvvm reactive-programming web-components web-standards

What is Quel

Quel is declarative, simple, easy, pure javascript frontend framework.

Our goal

The development goal is to simplify the increasingly complex frontend development.

The main features

Simple and declarative view

<div>
  <form data-bind="add|preventDefault">
    <input data-bind="task">
    <button data-bind="disabled:task|falsey">add</button>
  </form>
</div>
<ul>
  {{ loop:taskList }}
  <li data-bind="onclick:select">
    <span data-bind="class.selected:taskList.*.selected">{{ taskList.* }}</span>, 
    <button data-bind="delete">X</button>
  </li>
  {{ endloop: }}
</ul>

Simple class to store and manipulate state

class State {
  task = "";
  taskList = [];
  selectedIndex;
  get "taskList.*.selected"() {
    return this.selectedIndex === this.$1;
  }

  add() {
    this.taskList = this.taskList.concat(this.task);
    this.task = "";
  }

  delete(e, $1) {
    this.taskList = this.taskList.toSpliced($1, 1);
    this.selectedIndex = undefined;
  }

  select(e, $1) {
    this.selectedIndex = $1;
  }

  /** @type {{string,string[]}} special property, describe dependent properties */
  $dependentProps = {
    "taskList.*.selected": [ "selectedIndex" ],
  }
}

See todo list sample

Getting Start

To use Quel, import the necessary functions from the CDN or the downloaded file using the import declaration.

Example for CDN

<script type="module">
import { registerComponentModules } from "https://cdn.jsdelivr.net/gh/mogera551/quel@latest/dist/quel.min.js"; // CDN
</script>

Example for downloaded file

<script type="module">
import { registerComponentModules } from "./path/to/quel.min.js"; // path to downloaded file
</script>

Install Test

Display Welcome to Quel.

<!DOCTYPE html>
<html lang="ja">
<meta charset="utf-8">

<myapp-main></myapp-main>

<script type="module">
import { registerComponentModules } from "https://cdn.jsdelivr.net/gh/mogera551/quel@latest/dist/quel.min.js"; // CDN

const html = `
<div>{{ message }}</div>
`;

class State {
  message = "Welcome to Quel";
}

registerComponentModules({ myappMain:{ html, State } });
</script>
</html>

The development flow

In component-based development, you will proceed with the following steps:

Write custom elements in HTML

You can use autonomous custom elements and customized built-in elements for custom elements. The custom element name must include a dash -.

Example for custom elements index.html

<!DOCTYPE html>
<html lang="ja">
<meta charset="utf-8">

<!-- autonomous custom element -->
<myapp-main><myapp-main>

<!-- customized built-in element -->
<div is="myapp-main"></div>

</html>

Create corresponding single file component

A single file component consists of a class that stores and manipulates state, an HTML template, an css. Here, it is referred to as main.sfc.html.

example

<script type="module">
export class State {
  message = "welcome to quel";
}
</script>

<div class="message">{{ message }}</div>

<style>
div.message {
  color: red;
}
</style>

Define the class to store and manipulate state

Define the State class that stores and manipulates the state of the component. Use script tag with type="module" attribute. By declaring members that store state as fields within the class, you can handle the state as properties of the class. Create methods within the class to manipulate the state. Declare with the class name State and export it. You can also use accessor properties using getters. note:When using accessor properties, it is necessary to define dependencies.

Example for State class of single file component main.sfc.html

<script type="module">
export class State {
  message = "welcome to quel";
  count = 0;
  animals = [{ name:"dog" }, { name:"cat" }, { name:"mouse" }];
  countUp() {
    count++;
  }
}
</script>

Define the HTML template.

Define the HTML that will serve as the content of the component. You describe the embedding of properties defined in the State class, the association of attribute values of html elements, the association of events, conditional branching, and repetition.

Example for HTML template of signle file component main.sfc.html


<div class="message">{{ message }}</div>

<button data-bind="countUp">count up, count={{ count }}</button>

{{ if:count|gt,0 }}
  count > 0
{{ else: }}
  count = 0
{{ endif: }}

{{ loop:animals }}
  <div>{{ animals.*.name }}</div>
{{ endloop: }}

Define the CSS.

Define the CSS that will serve as the content of the component. Use style tag.

Example for HTML template of signle file component main.sfc.html

<style>
div.message {
  color: red;
}
</style>

Associate custom elements with component modules

You associate the single file component with the custom element name using the registerSingleFileComponents function.

index.html

import { registerSingleFileComponents } from "https://cdn.jsdelivr.net/gh/mogera551/quel@latest/dist/quel.min.js"; // CDN

registerSingleFileComponents({ "myapp-main":"./main.sfc.html" });

Tutorial

First

The file structure used in the tutorial is as follows.

--+-- index.html
  |
  +-- main.sfc.html

In index.html

Unless otherwise stated, the tutorial will use the contents of the following index.html.

index.html

<!DOCTYPE html>
<html lang="ja">
<meta charset="utf-8">

<myapp-main></myapp-main>

<script type="module">
import { registerSingleFileComponents } from "https://cdn.jsdelivr.net/gh/mogera551/quel@latest/dist/quel.min.js"; // CDN

registerComponentModules({ "myapp-main": "./main.sfc.html" });
</script>
</html>

In main.sfc.html,

In the tutorial, we will mainly discuss main.sfc.html.

main.sfc.html

<script type="module">
export class State {
  // (State)

  // (Manupilate)

}
</script>

(HTML Tempate)

Step 1. Embedding properties

Example main.sfc.html

<script type="module">
export class State {
  message = "welcome to quel";
  // #message NG, cannot use private fields
  // $message NG, cannot use name starting with $ 
}
</script>

<div>{{ message }}</div>

See result.

See source.

Step 2. Property Binding

The content of the html variable in main.js

<div>
  <div>{{ message }}</div>
  <!-- bind ViewModel.message to div.textContent -->
  <div data-bind="textContent:message"></div>
  <!-- input element, bind ViewModel.message to input.value -->
  <input type="text" data-bind="value:message">
</div>
<div>
  <div>{{ season }}</div>
  <!-- input element, bind ViewModel.season to select.value -->
  <select data-bind="value:season">
    <option value="spring">spring</option>
    <option value="summer">summer</option>
    <option value="autumn">autumn</option>
    <option value="winter">winter</option>
  </select>
</div>
<div>
  <!-- bind ViewModel.buttonDisable to button.disabled -->
  <!-- bind ViewModel.season to button.textContent -->
  <button data-bind="disabled:buttonDisable; textContent:season;"></button>
  <label>
    <!-- input element, bind ViewModel.buttonDisable to input.checked -->
    <input type="checkbox" data-bind="checked:buttonDisable">
    button disable
  </label>
</div>

The ViewModel class in main.js

export class ViewModel {
  message = "welcome to quel";
  season = "spring";
  buttonDisable = false;
}

See result.

See source.

Step 3. Event Binding

Content of the html variable in main.js

<button type="button" data-bind="onclick:popup">click here</button>
<label>
  <input type="checkbox" data-bind="onclick:checked">checked
</label>

ViewModel class in main.js

export class ViewModel {
  popup() {
    alert("popup!!!");
  }

  /**
   * @param {Event} e Event object
   */
  checked(e) {
    alert(`checked ${e.target.checked ? "on" : "off"}`);
  }
}

See result.

See source.

Step 4. Accessor Properties

Content of the html variable in main.js

<div>{{ counter }}</div>
<div>{{ doubled }}</div>
<!-- Disable the button after 5 presses -->
<button type="button" data-bind="onclick:countUp; disabled:over5times;">count up</button>

ViewModel class in main.js

class ViewModel {
  counter = 1;

  // Accessor property
  /**
   * Doubles the value of counter.
   * @type {number}
   */
  get doubled() {
    return this.counter * 2;
  }
  /**
   * Returns true if the value of counter is 5 or more.
   * @type {boolean}
   */
  get over5times() {
    return this.counter >= 5;
  }

  /**
   * Increment count
   */
  countUp() {
    this.counter++;
  }

  // dependencies
  $dependentProps = {
    // (accessor property name):(enumeration of referenced properties)
    "doubled": [ "counter" ],
    "is5times": [ "counter" ],
  };
}

See result.

See source.

Step 5. Output Filters

In terms of processing properties, it is similar to accessor properties, but differs in the following points.

Features of filters (differences from accessor properties)

List of Built-in Filter

Name Options Type Memo
truthy prop ? true : false
falsey !prop ? true : false
not !prop ? true : false
eq [any] prop == [any]
ne [any] prop != [any]
lt [number] prop < [number]
le [number] prop <= [number]
gt [number] prop > [number]
ge [number] prop >= [number]
embed [format] like printf, replace %s to prop
iftext [string1][string2] prop is true then [string1] else [string2]
isnull prop == null
offset [number] prop + [number]
unit [string] prop + [string]
inc [number] prop + [number]
mul [number] prop * [number]
div [number] prop / [number]
mod [number] prop % [number]
at ... string
charAt ... string
charCodeAt ... string
codePointAt ... string
concat ... string
endsWith ... string
includes ... string
indexOf ... string
lastIndexOf ... string
localCompare ... string
match ... string
normalize ... string
padEnd ... string
padStart ... string
repeat ... string
replace ... string
replaceAll ... string
search ... string
s.slice ... string
split ... string
startsWith ... string
substring ... string
toLocaleLowerCase ... string
toLocaleUpperCase ... string
toLowerCase ... string
toUpperCase ... string
trim ... string
trimEnd ... string
trimStart ... string
toExponential ... number
toFixed ... number
toLocaleString ... number
toPrecision ... number
at ... Array
concat ... Array
entries ... Array
flat ... Array
includes ... Array
indexOf ... Array
join ... Array
keys ... Array
lastIndexOf ... Array
a.slice ... Array
toLocaleString ... Array
toReversed ... Array
toSorted ... Array
toSpliced ... Array
values ... Array
with ... Array

Content of the html variable in main.js

<div>{{ message }}</div>
<div>{{ message|substring,4,15|toUpperCase }}<!-- QUICK BROWN --></div>

<div>{{ price }}</div>
<div>{{ price|toLocaleString }}<!-- 19,800 --></div>

ViewModel class in main.js

class ViewModel {
  message = "The quick brown fox jumps over the lazy dog";
  price = 19800;
}

See result.

See source.

Step 6. Conditional Branch Block

Content of the html variable in main.js

<button type="button" data-bind="onclick:change">change!!!</button>
{{ if:val }}
  <div>val is true</div>
{{ else: }}
  <div>val is false</div>
{{ endif: }}

ViewModel class in main.js

class ViewModel {
  val = true;
  change() {
    this.val = !this.val;
  }
}

See result.

See source.

Step 7. Loop Block

Content of the html variable in main.js

<ul>
{{ loop:animals }}
  <li>{{ animals.* }}</li>
{{ endloop: }}
</ul>
<ul>
{{ loop:fruits }}
  <li>{{ fruits.*.name }}({{ fruits.*.color }})</li>
{{ endloop: }}
</ul>

ViewModel class in main.js

class ViewModel {
  animals = [ "cat", "dog", "fox", "pig" ];
  fruits = [
    { name:"apple", color:"red" },
    { name:"banana", color:"yellow" },
    { name:"grape", color:"grape" },
    { name:"orange", color:"orange" },
    { name:"strawberry", color:"red" },
  ];
}

See result

See source.

Step 8. Initialization Event Handler

Content of the html variable in main.js

<ul>
  {{ loop:commits }}
  <li>
    {{ commits.*.sha|s.slice,0,7 }} - {{ commits.*.commit.message }} by {{ commits.*.commit.author.name }}
  </li>
  {{ endloop: }}
</ul>

ViewModel class in main.js

class ViewModel {
  commits = [];
  async $connectedCallback() {
    const response = await fetch("https://api.github.com/repos/mogera551/quel/commits?per_page=3&sha=main");
    this.commits = await response.json();
  }
}

See result

See source.

Step 9. Write Event Handler

Content of the html variable in main.js

display 
<select data-bind="value:display_count">
  <option value="3">3</option>
  <option value="4">4</option>
  <option value="5">5</option>
</select> items.
<ul>
  {{ loop:commits }}
  <li>
    {{ commits.*.sha|s.slice,0,7 }} - {{ commits.*.commit.message }} by {{ commits.*.commit.author.name }}
  </li>
  {{ endloop: }}
</ul>

ViewModel class in main.js

class ViewModel {
  display_count = "3";
  commits = [];
  async getCommits(per_page) {
    const response = await fetch(`https://api.github.com/repos/mogera551/quel/commits?per_page=${per_page}&sha=main`);
    return await response.json();
  }
  async $connectedCallback() {
    this.commits = await this.getCommits(this.display_count);
  }
  async $writeCallback(name, indexes) {
    if (name === "display_count") {
      // when changed display_count property
      this.commits = await this.getCommits(this.display_count);
    }
  }
}

See result

See source.

Step 10. Default Properties & Two-Way Binding

Tag Type Attribute Property
input radio checked
input checkbox checked
input other than above value
select value
textarea value
button onclick
a onclick
form onsubmit
other than above textContent

Content of the html variable in main.js

<div data-bind="message"></div>
<div>
  <input type="text" data-bind="message">
</div>
<div>
  <textarea data-bind="message"></textarea>
</div>
<div>
  <button type="button" data-bind="clearMessage">clear message</button>
</div>
<div>
  <select data-bind="num|number">
    <option value="1">1</option>
    <option value="2">2</option>
    <option value="3">3</option>
    <option value="4">4</option>
    <option value="5">5</option>
    <option value="6">6</option>
    <option value="7">7</option>
    <option value="8">8</option>
    <option value="9">9</option>
    <option value="10">10</option>
  </select>
  {{ double }}
</div>

ViewModel class in main.js

class ViewModel {
  num = 1;
  message = "";
  get double() {
    return this.num + this.num;
  }
  clearMessage() {
    this.message = "";
  }
  $dependentProps = {
    "double": ["num"]
  }
}

See result

See source.

Step 11. Binding Styles

Content of the html variable in main.js

<input type="number" data-bind="num|number; style.color:numberColor">

ViewModel class in main.js

class ViewModel {
  num = 5;
  get numberColor() {
    return this.num > 10 ? "red" : "black";
  }
  $dependentProps = {
    "numberColor": ["num"]
  }
}

See result

See source.

Step 12. Binding Classes

Content of the html variable in main.js

<style>
.over {
  color:red;
}
</style>
<input type="number" data-bind="num|number; class.over:isOver">

ViewModel class in main.js

class ViewModel {
  num = 5;
  get isOver() {
    return this.num > 10;
  }
  $dependentProps = {
    "isOver": ["num"]
  }
}

See result

See source.

Step 13. Using context variables and wildcards in repeat blocks

Content of the html variable in main.js

<style>
.adult {
  color:red;
}
</style>
{{ loop:members }}
<div data-bind="class.adult:members.*.isAdult">
  {{ members.*.no }} = {{ $1|offset,1 }}:{{ members.*.name }}, {{ members.*.age }}
  <button type="button" data-bind="onclick:popup">popup</button>
</div>
{{ endloop: }}

ViewModel class in main.js

class ViewModel {
  members = [
    { name:"佐藤 一郎", age:20 },
    { name:"鈴木 二郎", age:15 },
    { name:"高橋 三郎", age:22 },
    { name:"田中 四郎", age:18 },
    { name:"伊藤 五郎", age:17 },
  ];
  get "members.*.no"() {
    return this.$1 + 1;
  }
  get "members.*.isAdult"() {
    return this["members.*.age"] >= 18;
  }

  popup(e, $1) {
    alert(`選択したのは、${$1 + 1}行目です`);
  }

  $dependentProps = {
    "members.*.isAdult": [ "members.*.age" ]
  }
}

See result

See source.

Step 14. Manipulating array properties

Content of the html variable in main.js

<button type="button" data-bind="onclick:add">add grape</button>
<button type="button" data-bind="onclick:dump">dump fruits</button>
{{ loop:fruits }}
<div><input type="text" data-bind="fruits.*">{{ fruits.* }}</div>
{{ endloop: }}

ViewModel class in main.js

class ViewModel {
  fruits = ["apple", "orange", "strawberry"];
  add() {
    // Add elements with an immutable concat and assign to the fruits property.
    // Do not use mutable `push`.
    this.fruits = this.fruits.concat("grape");
  }
  dump() {
    alert(JSON.stringify(this.fruits));
  }
}

See result

See source.

Step.15 ToDoリストを作ってみよう

仕様

モックを見る

ToDo情報を格納するオブジェクトの型定義

ViewModelクラスで保持する情報

htmlの入力部分

ViewModelのaddメソッド

htmlのリスト部分

ViewModelのdeleteメソッド

完成

main.js

const html = `
<style>
  .completed {
    text-decoration: line-through;
  }
</style>
<div>
  <form data-bind="add|preventDefault">
    <input data-bind="content">
    <button data-bind="disabled:content|falsey">追加</button>
  </form>
</div>
<ul>
  {{ loop:todoItems }}
  <li>
    <input type="checkbox" data-bind="todoItems.*.completed">
    <span data-bind="class.completed:todoItems.*.completed">{{ todoItems.*.content }}</span>
    <button type="button" data-bind="delete">削除</button>
  </li>
  {{ endloop: }}
</ul>
`;

/**
 * @typedef {Object} TodoItem
 * @property {string} content
 * @property {boolean} completed;
 */

class ViewModel {
  /** @type {string} input text */
  content = "";
  /** @type {TodoItem[]} todo list, initial value empty array */
  todoItems = [];
  /**
   * add todo item
   */
  add() {
    this.todoItems = this.todoItems.concat({content:this.content, completed:false}));
    this.content = "";
  }
  /**
   * delete todo item
   * @param {Event} e
   * @param {number} $1 loop index
   */
  delete(e, $1) {
    this.todoItems = this.todoItems.toSpliced($1, 1);
  }
}

実行結果を見る

memo

install @rollup/plugin-terser

npm install @rollup/plugin-terser --save-dev

bundle

npx rollup -c
npx rollup -c rollup-dev.config.js

tag

git tag v0.9.28 
git push origin --tags