@7willows/sw-lib v3.0.5
sw-lib
Table of Contents
- web-components
- utils
sw-button
HTML
To use a component provide the tag sw-button with required attributes:
icon-disabled-
Example of use:
<sw-button
icon="magnifier"
disabled="false">
</sw-button>CSS
You can style the component changing the following options in the :host selector:
--foreground-color--primary-color-70--primary-color-40--primary-color-100
- Example of use:
:host {
--foreground-color: white;
--primary-color-70: rgba(98, 191, 124, .7);
--primary-color-40: rgba(98, 191, 124, .4);
--primary-color-100: rgba(98, 191, 124, 1);
}sw-modal
JS
To use the sw-modal call the modal() function, which takes an object as an argument. The object should contain the following keys:
header- can be astringor afunction({ close }):stringor afunction({ close }):JSXorJSXbody- can be astringor afunction({ close }):stringor afunction({ close }):JSXorJSXfooter- can be astringor afunction({ close }):stringor afunction({ close }):JSXorJSXlarge-boolean
The object cannot be specified without any parameters.
Example of use:
const result = await modal({
header: 'Welcome',
body: <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit.</p>,
footer: ({ close }) => <sw-button onClick={() => close(true)}>OK</sw-button>,
large: true
})CSS
You can style the component changing the following options in the :host selector on your index.html:
--background-color- modal background color--radius- modal border radius
Example of use:
:host {
--background-color: ghostwhite;
--radius: 5px;
}STM - State Manager
stm is a state manager for preact. It's main concept is borrowed from ELM Architecture, although it differs in many edge cases. The goal of he stm is to create a web component which could be plugged into any other framework.
This tutorial will present various examples that will help you understand how to work with stm. The examples can be found in the examples directory of this repository.
"Hello World"
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>STM Hello World example</title>
<script src="./index.compiled.js"></script>
</head>
<body>
<hello-world></hello-world>
</body>
</html>index.tsx (compiled to index.compiled.js)
import { stm } from '@7willows/sw-lib';
stm.component({
tagName: 'hello-world',
init() {
return [{}, null];
},
update() {
return [{}, null];
},
view
});
function view() {
return <p>Hello World!</p>
}the compilation was done using this command:
esbuild index.tsx --bundle --outfile=index.compiled.js --sourcemap --jsx-factory=h --jsx-fragment=Fragment --inject:./preact-shim.js --format=esmstm.component accepts a configuration object. In the above example we are specifying the minimum set of arguments needed for the component to run.
Make notice the tagName property. It must be a tag name of the new Web Component. The specification of custom elements requires the tag name to have a dash (-) that's why we cannot simply name our component: hello - this won't work. The good practice is to have a prefix for a certain library or project and use this prefix everywhere. For example in this library the prefix for all web components is sw-.
Basic interactivity
In the previous example we created a component that does nothig. To add some interactivity we need to understand the data flow of a stm component:
There are two most importand concepts in stm:
- state - a state is any data that represents current state of a component.
- msg - a message represents a change. It may be user clicking a button, form input or some outside change like a received data from a websocket connection.
Let's write a hello-who component which will demonstrate the above mentioned data flow:
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>STM Hello Who example</title>
<script src="./index.compiled.js"></script>
</head>
<body>
<hello-who></hello-who>
</body>
</html>index.tsx (compiled to index.compiled.js)
import { stm } from '@7willows/sw-lib';
type State = {
display: 'idle' | 'question' | 'welcoming';
name: string;
}
type Msg
= { type: 'introduce' }
| { type: 'input', name: string }
| { type: 'confirmName' }
stm.component({
tagName: 'hello-who',
init(): [State, stm.Cmd<Msg>] {
return [
{ display: 'idle', name: '' },
null
];
},
update(state: State, msg: Msg) {
if (msg.type === 'introduce') {
return [
{ ...state, display: 'question' },
null
];
}
if (msg.type === 'input') {
return [
{ ...state, name: msg.name },
null
]
}
if (msg.type === 'confirmName') {
return [
{ ...state, display: 'welcoming' },
null
]
}
return [state, null];
},
view
});
function view(state: State) {
if (state.display === 'idle') {
return <button onClick={{ type: 'introduce' }}>Introduce yourself</button>;
}
if (state.display === 'question') {
return <div>
<input type="text" onInput={(event: any) => ({ type: 'input', name: event.target.value })} />
<button onClick={{ type: 'confirmName' }}>OK</button>
</div>
}
return <p>Hello {state.name}!</p>;
}Make notice that both init and update functions return an array of two elements. The first element is a new state and the second is a command. Commands are required if we want to do something outside the component but we will talk about them later. In current component we don't need any commands so we just use null as a command.
Important things to consider in the above example:
initreturns a new state (along with a null in place of command)- next
viewrenders html according to thestatereturned by theinitfunction - when user interacts with the view
msgis returned (see:<button onClick={{ type: 'introduce' }}>Introduce yourself</button>i.e.). The event handler can also be a function like in here:<input type="text" onInput={(event: any) => ({ type: 'input', name: event.target.value })} />- this form can be used when we need aneventobject). - the generated
msggoes to theupdate(state, msg)function - the
updatefunction returns a newstatewith a command (in our case the command isnull). - the
viewfunction is executed again with the newstatereturned fromupdateand the rerender is invoked. - and so on in a loop...
Debug mode
Every view is rendered based on state, this means that having a state we can recreate how the app looked like in any moment. Quite often when debugging a stm component you will want to know what was the state before certain msg and what was the state after a msg. To show this info enable debug in the stm.component arguments:
stm.component({
tagName: 'hello-who',
debug: true,
init,
update,
view
});Now every change to state will be logged in a devtools console.
Shadow DOM
to enable shadow dom use shadow: true in the component options:
stm.component({
tagName: 'hello-who',
shadow: true,
init,
update,
view
});at this point the web component will be rendered in a shadow dom.
Component attributes
It's often the case that a web component has some attributes. Moreover the attributes are not something static - they can change at any given moment. To deal with changing attributes you have to translate an attribute to a msg, because this is how we deal with changes in stm. Additionally we need to specify the list of expected attributes with their types. Let's have a simple example: a counter that will increase it's displayed value every second - however this time the displayed value will come from the outside.
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>STM counter example</title>
<script src="./index.compiled.js"></script>
</head>
<body>
<my-counter count="0"></my-counter>
<script>
const $counter = document.querySelector('my-counter');
let count = 0;
setInterval(function () {
count += 1;
$counter.setAttribute('count', count);
}, 1000);
</script>
</body>
</html>The above script every second changes the count attribute of my-counter component.
index.tsx (compiled to index.compiled.js)
import { stm } from '@7willows/sw-lib';
type State = {
count: number;
}
type Msg = { type: 'attr', name: string, value: unknown }
stm.component({
tagName: 'my-counter',
propTypes: {
count: Number
},
attributeChangeFactory: (name, value) => ({ type: 'attr' as const, name, value }),
init(): [State, stm.Cmd<Msg>] {
return [
{ count: 0 },
null
];
},
update,
view
});
function update(state: State, msg: Msg): [State, stm.Cmd<Msg>] {
if (msg.type === 'attr' && typeof msg.value === 'string') {
state.count = parseInt(msg.value, 10);
} else if (msg.type === 'attr' && typeof msg.value === 'number') {
state.count = msg.value;
}
return [state, null];
}
function view(state: State) {
return <p>Current count: {state.count}</p>
}Make notice:
- we have to specify the list of attributes in:
propTypes - the
propTypestake attribute name and attribute type. The type of an attribute is a a value constructor (Boolean, Number, String, Object, Array) - you have to translate the attribute change into a msg. This is done in
attributeChangeFactory - the message:
{ type: 'attr', name: string, value: unknown }has thevalueproperty of typeunknownbecause we don't know what the outside world will pass to our component. That's why we need to be prepared for an unknown. Normally all attributes are strings however if this web component would be put into some other preact/react application then the specific data type would be passed instead of a string. That's why we should cover both cases:
if (msg.type === 'attr' && typeof msg.value === 'string') {
state.count = parseInt(msg.value, 10);
} else if (msg.type === 'attr' && typeof msg.value === 'number') {
state.count = msg.value;
}Making an asynchronous call
To make an async call we have to take advantage of the stm.Cmd<Msg> property returned from both init and update functions. This is known as a "command". It might be a Promise<Msg>, stm.Focus() or a custom event that should be dispatched on this component. To make an async call we will set a promise for Msg as a command. Consider this example:
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>STM GitHub Users example</title>
<script src="./index.compiled.js"></script>
</head>
<body>
<gh-users query="7willows"></gh-users>
</body>
</html>index.tsx (compiled to index.compiled.js)
import { stm } from '@7willows/sw-lib';
interface User {
login: string;
}
type State = {
isLoading: boolean;
hasLoadError: boolean;
query: string;
users: User[];
}
type Msg
= { type: 'loadFailed' }
| { type: 'loadSuccess', users: User[] }
| { type: 'attr', name: string, value: unknown }
stm.component({
tagName: 'gh-users',
debug: true,
attributeChangeFactory: (name, value) => ({ type: 'attr', name, value }),
init(): [State, stm.Cmd<Msg>] {
return [
{
isLoading: false,
hasLoadError: false,
users: [],
query: '',
},
null
];
},
update,
view
});
function update(state: State, msg: Msg): [State, stm.Cmd<Msg>] {
if (msg.type === 'attr' && typeof msg.value === 'string') {
state.query = msg.value;
state.isLoading = true;
state.hasLoadError = false;
return [state, loadUsers(msg.value)]
}
if (msg.type === 'loadFailed') {
state.hasLoadError = true;
state.isLoading = false;
return [state, null];
}
if (msg.type === 'loadSuccess') {
state.hasLoadError = false;
state.isLoading = false;
state.users = msg.users;
return [state, null];
}
return [state, null];
}
async function loadUsers(query: string): Promise<Msg> {
try {
const res = await fetch(`https://api.github.com/search/users?q=${query}`)
const json = await res.json();
return { type: 'loadSuccess', users: json.items };
} catch (err) {
console.error(err);
return { type: 'loadFailed' }
}
}
function view(state: State) {
return <div class="gh-users">
<h2>Github Users search for phrase: {state.query}</h2>
{state.isLoading && <div class="loader">loading...</div>}
{state.hasLoadError && <div class="danger">Error occured</div>}
found:
<ul>
<>
{state.users.map(user => <li>
{user.login}
</li>)}
</>
</ul>
</div>
}The flow of the program:
- when "query" attribute is changed (or set initially), the
updatefunction returns a state with a command. In our case a command is a promise for msg:return [state, loadUsers(msg.value)]. - in the
loadUserswe make an async call and return aMsg. - as a result the returned msg is an input for the next invocation of the
updatefunction. - at this point
updatefunction changes its state by setting the users - next
viewrenders the users
Make notice that when we started an async request the state was changed (isLoading property was added to the state). It is a good practice to show user that something is loading. Not every user has a good internet connection.
Dealing with outside world changes
Sometimes we have to listen to some events that are not DOM based. In such cases we have to manually dispatch a msg. A dispatched msg is used as an argument for the update function.
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>STM Resizer example</title>
<script src="./index.compiled.js"></script>
</head>
<body>
<my-resizer></my-resizer>
</body>
</html>index.tsx (compiled to index.compiled.js)
import { stm } from '@7willows/sw-lib';
type State = {
width: number;
height: number;
}
type Msg = { type: 'resize', width: number, height: number }
stm.component({
tagName: 'my-resizer',
willMount(cmp: any, dispatch: stm.Dispatch<Msg>) {
cmp.onResize = function() {
dispatch({
type: 'resize',
width: window.innerWidth,
height: window.innerHeight
});
}
window.addEventListener('resize', cmp.onResize);
},
willUnmount(cmp: any) {
window.removeEventListener('resize', cmp.onResize);
},
init(): [State, stm.Cmd<Msg>] {
return [
{ width: 100, height: 100 },
null
];
},
update,
view
});
function update(state: State, msg: Msg): [State, stm.Cmd<Msg>] {
if (msg.type === 'resize') {
state.width = msg.width / 2;
state.height = msg.height / 2;
return [state, null];
}
return [state, null];
}
function view(state: State) {
return <div style={getStyles(state)}></div>
}
function getStyles(state: State) {
return `
background-color: red;
width: ${state.width}px;
height: ${state.height}px;
`
}This component resizes its div whenever a window resize happens. Of course it would be much simpler to use width: 50%; height: 50% to do the job, but this is just an example.
Important thigs to notice in this example:
willMountis a function that is called whenever the component is about to be inserted into the DOMwillUnmountis a function that is called just before an element is removed from DOM- the
cmpattribute ofwillMountandwillUnmountis a preact component object. You cannot do much with it (and you shouldn't) however it serves very wall as an holder for storing handlers that should be later removed - whenever you add some event listener in
willMountdon't forget to remove it inwillUnmount - The
initfunction also receives thedispatchfunction as an argument however it's usually better to usewillMountandwillUnmountfunctions for dealing with outside changes becausewillUnmountallows us to clean up.
3 years ago