Quel is declarative, simple, easy, pure javascript frontend framework.
The development goal is to simplify the increasingly complex frontend development.
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
To use Quel, import the necessary functions from the CDN or the downloaded file using the import declaration.
import
declaration is required, so the script
tag needs type="module"
.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>
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>
In component-based development, you will proceed with the following steps:
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>
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 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 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 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>
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" });
The file structure used in the tutorial is as follows.
--+-- index.html
|
+-- main.sfc.html
In index.html
registerSingleFileComponents
functionregisterSingleFileComponents
functionUnless 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
,
html
variable, export
itState
class, you define a class that stores and manipulates state, export
it.In the tutorial, we will mainly discuss main.sfc.html
.
main.sfc.html
<script type="module">
export class State {
// (State)
// (Manupilate)
}
</script>
(HTML Tempate)
html
, enclose the property message
to be embedded in {{ }}
.State
, declare the property message
that stores the state as a field, and give it an initial value of welcome to quel
.State
class is extended by a Proxy after it is instantiated, so you cannot use private fields in the State class.$
are assigned to special properties, so you cannot use them.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.
ViewModel
class.textContent:message
,value:message
,value:season
...;
.
disabled:buttonDisable; textContent:season;
ViewModel
class is updated, the update is automatically reflected in the property of the html element.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.
on~
) of the html elements with the methods of the ViewModel
class.(element event property name):(ViewModel class method name)
in the data-bind
attribute of the element. ->onclick:popup
ViewModel
class take an Event object as an argument. ->checked(e)
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.
get
.$dependentProps
of the ViewModel
class.(accessor property name):(enumeration of referenced properties)
.
"doubled": [ "counter" ]
, "is5times": [ "counter" ]
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.
ViewModel
.|
and the filter name after the property in html.,
.|
.String
, Number
, and Array
.In terms of processing properties, it is similar to accessor properties, but differs in the following points.
Features of filters (differences from accessor properties)
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.
ViewModel
as conditions.{{ if:(ViewModel property) }} ... {{ endif: }}
. -> {{ if:val }} ... {{ endif: }}
{{ else: }}
to display false conditions.else if
.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.
ViewModel
.{{ loop:(ViewModel property) }} ... {{ endloop: }}
in html.
{{ loop:animals }} ... {{ endloop: }}
{{ loop:fruits }} ... {{ endloop: }}
*
.
animals.*
fruits.*.name
fruits.*.age
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.
$connectedCallback
, which is an initialization event handler, in the ViewModel
class.async
for the callback method.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.
$writeCallback
, which is a write event handler, in the ViewModel
class.ViewModel
.async
for the callback method.ViewModel
properties associated with input DOMs are automatically updated, but this is used when you want to do some other processing after updating.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.
data-bind="value:message"
->data-bind="message"
data-bind="textContent:message"
->data-bind="message"
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 |
ViewModel
class, if the properties of the input HTML elements are updated, the properties of the ViewModel
class are also automatically updated (two-way binding).input
, select
, textarea
.number
for type conversion. data-bind="num|number"
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.
ViewModel
class, write it as style.(style attribute of the element):(property of ViewModel)
. For example, style.color:numberColor
.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.
ViewModel
class, write it as class.(class name):(property of ViewModel)
. For example, class.over:isOver
.ViewModel
class is true, the class name is added to the class attribute of the element.ViewModel
class is false, the class name is removed from the class attribute of the element.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.
members.*.no
, members.*.isAdult
members.*.age
is referenced within members.*.isAdult
.this.$1
is referenced within the property.$1
in popup(e, $1)
$1|offset,1
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.
ViewModel
, create a new array with an immutable method and assign it. concat method
of add()
<input type="text" data-bind="fruits.*">
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.
index.html
を使用するdisabled
<ul>
でリスト表示するcompleted
)で実現html
のモック<style>
.completed {
text-decoration: line-through;
}
</style>
<!-- 入力部分 -->
<div>
<form data-bind="add">
<input type="text">
<button>追加</button>
</form>
</div>
<!-- リスト部分 -->
<ul>
<li>
<input type="checkbox">
<span>ToDoの内容</span>
<button type="button">削除</button>
</li>
<li>
<input type="checkbox" checked>
<span class="completed">ToDoの内容</span>
<button type="button">削除</button>
</li>
</ul>
content
completed
/**
* @typedef {Object} TodoItem
* @property {string} content
* @property {boolean} completed
*/
content
todoItems
class ViewModel {
/** @type {string} input text */
content = "";
/** @type {TodoItem[]} todo list, set empty array as initial value */
todoItems = [];
}
ViewModel
クラスのcontent
をバインド。data-bind="content"
submit
時、ViewModel
クラスのadd
メソッドを呼び出す。呼び出し時、preventDefault
フィルタで、event.preventDefault()
を実行する。data-bind="add|preventDefault"
data-bind="disabled:content|falsey"
disabled
プロパティとViewModel
クラスのcontent|falsey
をバインドcontent|falsey
は、!content
と同じ意味
<div>
<form data-bind="add|preventDefault">
<input data-bind="content">
<button data-bind="disabled:content|falsey">追加</button>
</form>
</div>
push
ではなくconcat
を使うthis.content = ""
class ViewModel {
/**
* add todo item
*/
add() {
const { content } = this;
this.todoItems = this.todoItems.concat({ content, completed:false });
this.content = "";
}
}
{{ loop: }} ~ {{ endloop: }}
で括るdata-bind="todoItems.*.completed"
{{ todoItems.*.content }}
data-bind="class.completed:todoItems.*.completed"
ViewModel
クラスのdelete
メソッドを呼び出す。data-bind="delete"
<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>
delete
メソッドは、繰り返しブロック内にあるため、第2引数にインデックスが渡るsplice
ではなくtoSpliced
を使う
class ViewModel {
/**
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);
}
}
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