web_spa 2.1.0-alpha.1
web_spa: ^2.1.0-alpha.1 copied to clipboard
PotatoBeans SPA framework.
PotatoBeans SPA Framework #
The PotatoBeans SPA framework is created to ease the development of a web application, while still retaining the vanilla HTML/CSS development workflow.
This SPA framework is developed and maintained by PotatoBeans and is now available in Github. It is now published as open source as a way for PotatoBeans clients to use without having the codes to be distributed for each project.
Architecture #
There are three main parts of the framework: component, animation, and router.
Component #
The Component
base class is the main building block of the web application
written using PotatoBeans SPA framework. Unlike in other frameworks, Component
is not a widget. You don't just compose Component on top of other Component.
Component is just an abstraction of a bunch of HTML codes, grouped together so
that they can be reused and controlled consistently. Using Components, you
can control an HTML page OOP-way, which hopefully makes it easier and cleaner.
There are two types of Component: RenderComponent
and StringComponent
.
In previous versions (ABSIS and potatobeans.id), RenderComponent
is split
into two: StatelessComponent
and StatefulComponent
, which no longer exist.
Each component stores a small version of HTML DOM. It contains a DOM which
contains a tree of HTML tags. It can be manipulated, like you can have the
<div>
element inside a component have its display
CSS property be changed, even
before the component was rendered. They are represented by Element
class,
a native JavaScript class that is also available in Dart. A <div>
tag is
represented by a DivElement
, an <a>
tag is represented by an AnchorElement
,
and so on. Component stores a tree that is composed of these Element
classes.
RenderComponent
RenderComponent is a wrapper class for HTML codes which need to be rendered
directly on the page. HTML tags inside this components can be rendered directly
using renderTo
method, and can be unrendered using unrenderTo
. Although
you are not intended to use these methods, a Router is sometimes used to render them.
The basic of all Component, including RenderComponent, are id
and baseInnerHtml
properties.
They will be explained shortly.
To create a RenderComponent, create a class that extends the RenderComponent
class.
// MyComponent.dart
class MyComponent extends RenderComponent {
MyComponent(String id) : super.empty(id) {
baseInnerHtml = '''
<div id="$id">Hello world!</div>
''';
}
@override
void loadEventHandlers() {
}
}
By convention, you create a RenderComponent in a separate file, just like creating a Java class.
StringComponent
StringComponent is a wrapper class for HTML codes which need to be included in other RenderComponent. You can say that a RenderComponent is composed of many StringComponents. You can also compose a StringComponent with other StringComponents, but this is sometimes not necessary and should be avoided unless really needed. The reason behind this is because a StringComponent requires a valid RenderComponent as parent. It does not have to be direct parent though. A StringComponent must always be contained inside a RenderComponent, for it to be rendered together to the DOM.
To create a StringComponent, create a class that extends the StringComponent
class.
class MyStringComponent extends StringComponent {
MyStringComponent(RenderComponent parent, String id) : super.empty(parent, id) {
baseInnerHtml = '''
<div id="$id"></div>
''';
@override
void onComponentAttached() {
}
}
}
Unless quite large, the StringComponent does not need to be in its own file.
The StringComponent needs a reference to its parent RenderComponent so that when
the RenderComponent is rendered, the StringComponent is attached to it and gets
rendered too. For this reason, the StringComponent has onComponentAttached
method
that is called when the StringComponent is attached to the RenderComponent.
What Does it Mean by Attached to RenderComponent
Because Component stores string HTML codes as Element
, there still needs a time
when the Component converts developer-defined HTML strings into a mini-DOM
represented by a collection of Element
. This is done automatically when you
fill baseInnerHtml
for RenderComponent, but not for StringComponent. This
behavior may change in the future, but in order so that a StringComponent can
be manipulated by the parent RenderComponent, the StringComponent DOM representation
needs to be attached to the RenderComponent mini-DOM. In other words, a tree
of Element
classes in StringComponent needs to be moved together into a tree
of Element
classes in its parent RenderComponent. This is why it needs to be
attached manually, originally.
This behavior have changed however, with the introduction of parent
parameter
in the constructor. The parent will now attach all their children StringComponents
when the parent baseInnerHtml
is edited.
Before the component is attached, elem
is null. See about elem
in baseInnerElement
section.
The id
Parameter
To create a Component, you will need an id
. An id
is identical to a HTML
id
attribute, therefore it must be unique. Another rule when creating a
Component is to also put the id in the outermost HTML tag that you define
in baseInnerHtml
. Looking at the MyComponent
example above, the <div>
tag
has id="$id"
attribute. This is needed, as of today. Later on in the future
the id may no longer be needed and can be replaced with a unique random ID or hash.
The id is needed to uniquely identify and select the component through DOM.
The baseInnerHtml
Parameter
The baseInnerHtml
parameter is used to create a DOM for the Component to store.
You create a component by writing a string of HTML tags in here. It supports complete
HTML and CSS syntax, just like writing in an HTML file.
To create the style for your component, just add classes to your component and create a style on a separate CSS file.
The baseInnerElement
Parameter and queryById()
Method
The lesser use baseInnerElement
parameter is used to store the DOM representation
of the baseInnerHtml
, in form of Element
s. You do not usually manipulate it
directly, and it cannot be accessed directly. It is used to do querySelector upon,
to select some elements. To do that, you can use queryById
(and other methods in the future)
to select an element from the tree. It is a shortcut of elem.querySelector('#$id')
.
The elem
parameter is used to fetch baseInnerElement
, for querySelector. When
you do elem.querySelector
, done using queryById
for example, you can select
an element from the tree based on the selector. This way, using queryById, you can
find an element of a certain ID which is contained in the component. This is
the only way you can select subelements in a RenderComponent or StringComponent,
if you wish to create an animation or create an event handler.
In StringComponent, elem
is null until the element is attached. You can
use queryById
or other variants involving elem
at onComponentAttached
or
loadEventHandler
.
Rendering Steps of RenderComponent
When a RenderComponent gets rendered, using renderTo
or some other methods
like renderPrepend
, some methods are executed. The order are as follows:
preRender
renderTo
postRender
preUnrender
unrenderFrom
postUnrender
The postRender
and preRender
are special, because loadEventHandlers
is
called after render (on postRender
), and unloadEventHandlers
is called
before unrender (on preUnrender
). They are called this way so that event
handlers are active only when a component is rendered. Read about them in
Event Handling section.
Do not run a long running task in preRender
, although preRender actually
returns a Future. They are designed usually to do some UI positioning and setup,
and is lesser used than postRender. The same goes for postUnrender
. A long
running preRender
will cause the UI to look like hanging.
Event Handling
Currently, the RenderComponent and StringComponent uses MEventHandler
mixin,
which gives them the ability to store event handler subscriptions. The mixin
introduces two methods, loadEventHandlers
and unloadEventHandlers
. They are
used to load event handlers, for example by doing onClick.listen
on a certain
element. This is because loadEventHandlers
is called in postRender
, used
to attach those event listeners to the DOM. Although you can actually load event
handlers on an unrendered component, it is better to do it after so that event
handlers do not listen on a component that users cannot interact with.
To load events, use event handling method like addOnClickTo
(and others in the future)
to add onClick event to an Element
. Event handling uses a wrapper function due
to the fact that the doing onClick.listen
returns a StreamSubscription
object,
which needs to be cancelled by calling cancel
when you want to destroy the
event listener and prevent memory from leaking. To do that automatically, you
can use such wrapper method like addOnClickTo
so that the Component can store
the resulting StreamSubscription and cancel them all during unloadEventHandlers
.
To create an event, follow this example
// MyComponent.dart
class MyComponent extends RenderComponent {
MyComponent(String id) : super.empty(id) {
baseInnerHtml = '''
<div id="$id">
<button id="$id-button">Test</button>
</div>
''';
}
@override
void loadEventHandlers() {
addOnClickTo(queryById('$id-button'), (event) {
event.preventDefault();
print('this is executed when $id-button is clicked');
});
}
}
The example above shows a button with ID '$id-button'. It's a good idea to
name subelements with ID that has prefix of the component ID. This ensures that
all HTML tags in the application have unique ID. The component stores somewhere
a StreamSubscription of the onClick listener created. When the component itself
calls unloadEventHandlers
, during postUnrender
, it will cancel the
StreamSubscription for you, preventing memory leak.
In the previous versions, the developer of the component is responsible for
storing SteamSubscription and manually cancelling it, by overriding
unloadEventHandlers
and calling cancel. This can still be achieved by
calling onClick.listen
manually which will return a StreamSubscription<MouseEvent>
class. The object can be stored as the component attribute and cancelled in
unloadEventHandlers
, if you do not want the component to cancel it automatically for you.
Event handling works for other events as well, such as onDoubleClick
.
StreamSubscription
StreamSubscription is a generic class. It's native in Dart. It represents your
subscription to an event, for example onClick of an element. The only method
that you usually use is cancel()
, which will stop the onClick event to call
the registered callback function.
onClick.listen
will return StreamSubscription<MouseEvent>
type.
Waypoint
Waypoint is a new library implemented to detect if an element is displayed on
screen. Waypoint works by continuously checking whether an element is displayed
on screen by tracking its position relative to a viewport. The name waypoint
came from the same jQuery waypoint functionality.
To use waypoint, add your component and a handler function to the constructor, and an optional offset value.
waypoint = new Waypoint(queryById('some-element-id'), () {
// Do something when the element come to view
});
waypoint.loadEventHandler();
To initialize waypoint, call waypoint loadEventHandler()
function to register
waypoint internal onScroll
listener. You call it naturally in your component
loadEventHandlers()
.
To dispose waypoint event listener, which
will release the memory that it uses to prevent memory leak, call unloadEventHandler()
method in your component unloadEventHandlers()
.
Waypoint is useful for example to trigger an animation to display the element itself.
This is useful only for animations that do not involve unrendered components, for
example CustomAnimation.displayFadeIn
(explained shortly), which will change
the element display
from none
to something else. When the element is using
none
as the display
parameter, the component has no height/width, which
makes waypoint not working properly.
How Waypoint Works
Waypoint works by registering an document.onScroll
event listener. When a
scroll event is triggered, which is multiple times in a row during scrolling,
waypoint will check the element position on screen. The position is retrieved
using Element.getBoundingClientRect()
(MDN).
The function returns 4 values, top
, left
, width
, height
. To make waypoint
works with vertical scroll, we look at the element top
and height
values. Please
read the MDN documentation to know the detail of these parameters.
The top
value basically indicates the distance between the top left point
of the element to the current viewport position. That means, the more the user
scrolls down the page, the top
value decreases. We can then check if the
top
value eventually becomes <= than the viewport height to see if the
element is finally shown in the viewport.
Waypoint offset
Read more about this in Waypoint
class documentation.
Animation #
The animation is introduced to help creating an animation over an element. It
resolves in the CustomAnimation
class.
CustomAnimation
The CustomAnimation
class is an animation controller to control an animation
of an element. To play an animation forward, use play()
. To rewind the
animation, use rewind()
.
CustomAnimation(Element element,
Map<String, String> from,
Map<String, String> to,
Duration duration,
{Map<String, String> prePlay,
Map<String, String> postPlay,
Map<String, String> preRewind,
Map<String, String> postRewind}) {
}
You supply an HTML element that you receive from elem
or queryById
(and other
query variants). The from
is a map of CSS properties, which will be set
to the element before it is played. The to
parameter is a map of CSS properties,
which will be set to the element after it has done playing (before rewind).
You can then create such maps to animate the element from opacity 0 to 1.
// from
{'opacity': '0'}
// to
{'opacity': '1'}
The duration
parameter is straightforward, it accepts a Duration
object which
dictates how long the animation will run. CustomAnimation
currently does not
accept easing options.
The prePlay
, postPlay
, preRewind
, and postRewind
are properties which
are set to the element before play, after play, before rewind, and
immediately after rewind. The prePlay
for example is useful to set the component
display
from none
to something else, probably to fade in the element. The
postRewind
can be used for the same thing, by setting the display
to none
so that when an element fade in animation has been rewound, the display is set
to none
so that the element is visually unrendered from the browser.
To create a CustomAnimation, follow this example.
var animation = CustomAnimation(queryById('someid'),
{'left': '-30px'},
{'left': '0'},
Duration(millisecond: 200)
);
animation.init();
After creating an animation, you need to initialize it by calling init()
. This
will set initial CSS properties to the element. Because of this, you need to make
sure that the component has been attached when using a StringComponent
. You can
do this by creating CustomAnimation
and init()
under onComponentAttached()
.
CustomAnimation _animation;
@override
void onComponentAttached() {
_animaton = CustomAnimation.fadeIn(queryById('$id-button'), Duration(milisecond: 200))
..init();
}
To start/rewind the animation, you can do
await animation.play();
await animation.rewind();
play()
and rewind()
return Future<void>
, which you can use by calling
then()
or using await
(async
/await
is preferred).
Instead of creating a CustomAnimation your own, try to use the shortcut by using
some named constructors like CustomAnimation.displayFadeIn
and others (see CustomAnimation.dart).
Using such shortcut constructors you do not need to define your own from
and to
state properties, and you just need to supply the element and duration.
Animation Queue (MAnimationQueue
)
The MAnimationQueue
mixin provides a component with animation queue. The animation
queue is created since the potatobeans.id project. Many of potatobeans.id components
require a queue of animations to animate each "slide" of the web. This involve
animating a lot of element in sequence, or simultaneously. To help with this,
we introduced MAnimationQueue
(MAnimation
in previous versions).
An animation queue is just a queue of AnimationElement
, which is parent of
CustomAnimation
and CustomAnimationDelay
(and the deprecated CustomAnimationMultipler
).
To add an AnimationElement, whether it's a real animation or just a delay in the
queue, you can use
addSingleAnimation() // Add a single animation to the queue
addSimultaneousAnimations() // Add a set of animations that will be played simultaneously
addAnimationDelay() // Add a delay in the queue (in the form of CustomAnimationDelay)
Let's say you have CustomAnimation A
, B
, C
, and D
. You want to play
those animations in this order: A
-> B
and C
together -> delay -> D
. To
do that, just do
addSingleAnimation(A);
addSimultaneousAnimations([B, C]);
addAnimationDelay(Duration(milisecond: 200)); // Delay as Duration object
addSingleAnimation(D);
Just like creating CustomAnimation manually, you need to initialize them. To do this, you can just call
initAnimations();
which will initialize all animations in the queue for you.
To play your animation queue, there are two options, playing the queue sequentially, like a normal queue, or overriding the queue and playing all animations simultaneously. Use these methods to play your animations.
playAnimationSequential(); // Play the queue sequentially like a normal queue
playAnimationSimultaneous(); // Override the queueing behavior and play everything simultaneously
// Rewinding
rewindAnimationSequential();
rewindAnimationSimultaneous();
Those methods return a Future object which waits until all animation to finish.
Router #
The router, like a controller, holds what is called a routing table. The routing
table is a set of rules or a function that tells the router to render a certain
RenderComponent
over an element when the routing table matches. Currently
there is only one type or Router, which is UrlComponentRouter
, which renders
the component based on the URL. This is what makes SPA work.
To use a Router, you need to give it what is called routerElementBind
. routerElementBind
is a HTML ID, used to point in which HTML element the router is acting. The router
replaces the content of the HTML element pointed by routerElementBind
based on
the routing table. If you give it an element with ID test
, then the HTML
element with ID test will have its content be controlled by the router. In this
case if it's the UrlComponentRouter, it will have the content changed based on
the current URL.
All routers interact with Route
object, which contains the RenderComponent
to render, and other additional parameters explained shortly.
Routing Based on URL Using UrlComponentRouter
An SPA and a conventional web application differ in the way they render web pages.
In SPA, the browser no longer reloads and downloads new pages everything the user
chooses to change page, for example by clicking a link. In SPA, the web is designed
like a mobile application. Click another menu should not reload the whole page,
but just a portion of the page. This way, only one HTML page needs to be loaded
at a time to render the whole application. The router just replaces some parts
of the page with some other elements, depending on user interaction. UrlComponentRouter
makes this possible.
UrlComponentRouter
listens to what is called PopStateEvent
. This event is a
native browser event that is fired everytime the browser history is changed, for
example when the user clicks on the back/forward button. When a user clicks on back/forward
button, the browser will change the URL in the address bar, and UrlComponentRouter
,
listening to PopStateEvent
, can replace some parts of the page with a new
component, according to the routing table that has been setup in the router.
The problem with this is, clicking a link (for example an <a href="/anotherpage">
),
does not trigger PopStateEvent
. The default behavior of clicking a link is
to reload a new page pointed by /anotherpage
. For the router to work, developer
is obliged to register an onClick
handler (by using addOnClickTo
) over elements
like <a>
or <button>
and do event.preventDefault()
to prevent this from happening.
The next step is to emit an artificial PopStateEvent that will be listened by the
router.
Emitting a PopStateEvent
Suppose that you have this component (extending from the MyComponent
example above).
// MyComponent.dart
class MyComponent extends RenderComponent {
MyComponent(String id) : super.empty(id) {
baseInnerHtml = '''
<div id="$id">
<button id="$id-button">Test</button>
</div>
''';
}
@override
void loadEventHandlers() {
addOnClickTo(queryById('$id-button'), (event) {
// Default behavior is prevented
event.preventDefault();
});
}
}
Focus on the loadEventHandlers
part. In there, we select the <button>
element
inside the component by using queryById
, and registers a callback function.
A callback function for onClick event requires a single argument, typically named
event
, which hold an Event
object. You mostly do not need to interact with it
except for doing event.preventDefault()
, which will prevent the default callback action
of the event to happen. You can then proceed to create a PopStateEvent.
To create a PopStateEvent
, UrlComponentRouter
have a static helper function
that you can use, emitPopState(url)
. It accepts a single String argument, which
is a URL. Calling emitPopState('/anotherpage');
will do:
- It will create a new page history item in the browser. The user
can then click back on the browser to go back to previous URL. It is done by
using
history.pushState
function, native to the browser. Because of this a new history item is added and the URL in the browser address bar will change - Throw a PopStateEvent with URL set to
/anotherpage
- All active
UrlComponentRouter
s will listen to that event and see if it matches a certain pattern - If it matches, the router will check its routing table
- The router renders a new component on HTML element pointed by
routerElementBind
The UrlComponentRouter
urlPattern
The constructor of UrlComponentRouter
accepts two parameters, urlPattern
, which
is a string with certain syntax, and the routerElementBind
. The urlPattern
is
a string which contains a pattern for the router to match with the current URL.
Currently, it accepts a placeholder and a normal URL format. Consider this pattern
/[page]
The router will then matches all URL like /home
, /test
, /dkjdkejlakj
and so on
but not /home/test
. The [page]
placeholder causes the router to look at an
arbitrary string. The placeholder needs to be:
- In lowercase string
- Starts with
[
and ends with]
- Cannot contains symbol, only regex
[a-z]
You can add many more placeholder, separated with /
. The pattern
/[page]/[sub]
will match /home/test
, /abhds/skjde
, and /test/home
but not /test
, /test/test/test
,
and so on.
If the pattern ends with /
, that means it will do a prefix match. The
pattern
/[page]/
will match /home/test
and /home
, but not /
. You can also add an exact
match URL in the pattern, like so
/home/[sub]
which will match /home/test
, but not /test/home
, /home
, and so on.
The placeholder is used to match with the routing table. Although the router
match the pattern with the URL, the router does not stop there. It calls the
routeTableMatcher
function to further match the parameter to render the
component.
If the router pattern does not match the URL, it will do nothing, not event unrendering the current rendered component.
The routeTableMatcher
Function
UrlComponentRouter
do not use a map as its routing table, but a function.
The function signature is
Route Function(Map<String, String> urlParams);
A RouteTableMatcher
function is a function that returns a Route
object,
indicating the chosen route. It accepts a parameter, a map, which contains
the parameter extracted from the placeholders that you place in the pattern.
See this example.
// Pattern
/home/[page]/[sub]/
// URL
/home/test/test1 ==> does not match pattern, ignored
/home/test/test1/a ==> {page: test, sub: test1}
/home/test1/a/b ==> {page: test1, sub: a}
This behavior is still experimental and will be revised in the future.
In the RouteTableMatcher
function that you create, you can then choose to
return a Route
based on how you match the parameters. For example:
Route myMatcher(Map<String, String> params) {
if (params['page'] == 'test' && params['sub'] == 'a') {
return Route(...); // Return a component
} else {
return Route(...); // Return other component
}
}
You can also return null
, which tells the router to ignore the URL and does not
do anything.
The Route
Class
A Route
indicates a RenderComponent to render and some additional actions that happen
pre/post render/unrender. The Route
takes a mandatory RenderComponent argument
and 4 optional arguments which are functions. Please look at the constructor
at router/Route.dart
for details.
The beforeRender
and beforeUnrender
is a function with this signature:
Future<bool> Function();
These functions must return a boolean (in the form of a Future). The boolean
value is used by the router to decide whether to continue rendering or unrendering
the component. If you give it false
value then it will stop rendering the component,
and does not fire the afterRender
or afterUnrender
function.
The beforeRender
for example is useful for changing the document title, especially
when used with the uppermost router (the router that sits in the root component, or
the main component as you may call it, which is usually defined in main.dart). The
root/uppermost router is used usually to change a large portion of the page, which
gives the illusion of the page changing to the user. This usually involves changing
the title of the browser page.
Future<bool> myBeforeRenderFunc() {
document.title = 'my title';
return Future.value(true);
}
The afterRender
and afterUnrender
signatures are like the before counterparts,
but do not return any values (Future<void>
).
The Route id
The route ID for this route, must be unique, and needs
to be unique just for the router where the Route
is registered.
It is used to check whether the currently rendered route is the same
with newly rendered Route. If a new request to render a route is given,
and the route has the same id as the currently rendered route, router is
intended to do nothing.
It's a good practice to fill it a unique name that describes the component being
rendered like the URL for the component. For the order page, you can give it
for example /order
or /order/
if the component holds another router inside,
as long as the same component is always given the same ID so that the same
component won't be rendered twice even if it's given twice in the RouteTableMatcher
function.
Lazily Loading a Component
The Route
represents a RenderComponent as FutureOr<RenderComponent>
. This means
that Route can accept a Future instead of the component itself. There is a good
reason for this. Because the component can be represented by a future instead of
a real component, the router can wait for the component to be available first
before rendering it. The router will execute beforeRender
function first
and then wait for the component to exists before calling rendering it to routerElementBind
.
With this model, you can separate your component in a separate library and have it loaded first before rendering it using the router. See this example.
import 'package:capio_web/index.dart' deferred as index;
import 'package:capio_web/spa.dart';
...
Route mainRouterMatcher(Map<String, String> urlParams) {
Future<RenderComponent> createIndexPageComponent() async {
await index.loadLibrary();
return index.IndexPageComponent();
}
if (urlParams.isEmpty) {
return Route(createIndexPageComponent());
}
return Route(...);
}
In the example above, IndexPageComponent
is only available in index package,
which will be lazily loaded by the browser (indicated by deferred
keyword).
We created a wrapper function to wait for the library to be loaded first
before instantiating an IndexPageComponent
. This wrapper function naturally
should return a Future
, and the Future object is passed to the Route
component. This way, the router will then wait for the Future to complete
and render the component. Currently, this is the only way to lazily load
components and render it on screen.
Getting Started #
Start by importing this spa
package, which contains all the components needed for
the framework to work.
Creating the HTML Page #
No web application can exist without a single .html
file given to the browser from
the server. A HTML page is needed to load our JavaScript file, including our
CSS files. This is the most basic HTML file that is enough to start our script.
<!DOCTYPE html>
<html>
<head>
<title>My Website</title>
<link href="/css/mycss.css" rel="stylesheet" />
<!-- put other css file links here -->
<script type="application/javascript" src="/js/main.dart.js" defer="defer"></script>
</head>
<body id="body">
</body>
</html>
We will see why we add id="body"
soon.
This page when given to the browser will load mycss.css
file and the main.dart.js
script, assuming that your JavaScript file is named as such. Our application is
written in Dart but will be compiled to a single JavaScript file.
In an SPA, whatever URL the browser is pointing, usually, the same HTML file
is returned. In SPA, we embrace CSR (Client-side Rendering) mindset where the
client renders all the views of the application. The server just sends a script
like main.dart.js
which processes all the views and renders them to the screen,
according to the URL.
Planning Your Routes #
Before creating your application, make sure you already have a few design for a few screens/displays/pages in your application. This way you can think of which part of the page changes, to give the impression of the page changing. If you have a common web design, with a header and a body content and a footer, you may think that the body part of the web is the one that changes, and the header and footer parts change just a little bit, or maybe even not. This means that the body part of the application can be attached to a router.
If the whole part of the web changes, you can just attach the whole part of the
application to the router. This means that the topmost component (the root
component, or the main component) content is controlled by the router by
passing its ID to the router as routerElementBind
.
Let's see this example.
class MainComponent extends RenderComponent {
MyHeaderComponent header;
MainComponent([String id = 'main']) {
header = MyHeaderComponent(this);
baseInnerHtml = '''
<div id="$id" class="$id">
$header
<div id="$id-content"></div>
</div>
''';
}
@override
void loadEventHandlers() {}
}
class MyHeaderComponent extends StringComponent {
MyHeaderComponent(RenderComponent parent, [String id = 'header']) : super.empty(parent, id) {
baseInnerHtml = '''
<div id="$id" class="$id">My header</div>
''';
}
}
To aid with our design, we create our header as a StringComponent
. This help
separate the HTML codes and makes it easier to read. The header is then constructed
in the MainComponent constructor and used like in the example. The header is given
a reference to the parent RenderComponent using this
.
In our scenario, we want the div
element with ID $id-content
to be controlled
by a UrlComponentRouter
, because we want its content to change according to the
URL. To do this, we create a UrlComponentRouter
and bind it to the div.
// This assumes that id is always `main`
UrlComponentRouter router = UrlComponentRouter('/[page]/', 'main-content');
Initializing your Router and Root Component #
The root component, MainComponent
in the example above, is a component that
holds all other components in the application. Because it is not controlled by
any routers, you need to render it manually by calling renderTo
. Therefore,
in your main() function, you can finally instantiate MainComponent
and call
renderTo
directly to <body>
.
Because renderTo
accepts an ID as parameter, give an ID to your <body>
element.
The id is typically body
, but you can choose any name you like. This behavior
may change in the future.
void main() {
MainComponent().renderTo('body');
}
Because a router needs to be initialized, you will need to call the router
init()
function to let the router renders its first, initial, Route
. You can
do this in the postRender
function of the MainComponent
.
class MainComponent extends RenderComponent {
UrlComponentRouter router = UrlComponentRouter('/[page]/', 'main-content');
...
@override
Future<void> postRender() async {
await super.postRender();
await router.init();
}
...
}
Contributing #
TBD
Philosophy #
The PotatoBeans SPA framework was created so that we can use Dart cleanly for the web.
Why Use Dart #
Because we want to write clean code that is consistent and easy to read, we know that using pure JavaScript is not an option. This immediately throws jQuery away, although jQuery actually solves some of our problems. It provides a decent animation library, and do not introduce the need of too many industrialized components.
TypeScript is an alternative. React and other frameworks are available in TypeScript. However, TypeScript still feels like JavaScript. It helps at some point but writing TypeScript in most cases still feels like writing JavaScript, only stricter.
Dart on the other hand, provide a powerful feature: OOP. TypeScript already enhances the OOP capability of JavaScript, which is near non-exsistent, but Dart takes it even further and makes it feel like writing Java, which is consistent and strict. However, it does not make it feel like writing verbose codes, like Java, as it also supports some dynamic features JavaScript and other dynamic languages have. Therefore, it sits in the middle, providing benefits and best of both worlds.
Why This Framework #
Because the W3C and so many people continually enhance the capability of browser JavaScript, and Chrome and Mozilla have dominated the browser world, frameworks continue to follow their specifications. Browsers already provide a powerful way to manipulate HTML and CSS, which actually makes jQuery quite deprecated.
Browser native DOM manipulation functions have become so powerful that we can actually make a complete web application without the help of any frameworks. However, it's a user interface that we are interacting with, using just low-level functions will create horrible codes. That is why frameworks like Electron, Qt, Java Swing, exists, to prevent you from writing bad frontend codes.
Dart itself actually enhances the capability of native browser functions by adding some nice wrapper functions and classes. This further reduces the need of frameworks like AngularDart or React.js (that can't be used as it is not available in Dart, attempts have been made to port it to Dart though).
However, with how powerful JavaScript has become these days, Dart has provided
JS Interop functions and has now rolled out package:web
, a new web package
implementation, deprecating dart:html
. Through package:web
(and previously
dart:html
), this simple framework provides a way to manipulate DOM elements.
This (micro) framework has been created to aid with the structure of the HTML/JS
codes. It does not interfere with the original way of writing vanilla HTML/JS.
However, these codes are now "categorized" into components that you can create
through this framework. For example, you may want to create a sidebar and thus
create a SidebarComponent with this framework. All the HTML and interactions can
be put in this component. Being an OOP language, SidebarComponent is a class, which
can easily be used anywhere. With the combination of Dart, package:web
, and this
framework, we are able to better write HTML/JS codes with the original mindset, without
having to learn about too many of new logic provided by a lot of modern frameworks these days.
Why Bother Creating a Framework #
PotatoBeans SPA framework was not created to become a framework. It evolved from reusing codes when we tried to create a web application using pure Dart. We reuse codes and create libraries and abstractions, continue adding things. The framework was born in an old project, was enhanced a lot with inspirations from Flutter in some projects, and was then production-ready in many projects and have been used in all of PotatoBeans projects nowadays.
Because it was built from pure reusing codes (clean code) mindset, it is not
designed to compete with other frameworks. It is originally designed to keep PotatoBeans
frontend developers to write clean codes that follow many PotatoBeans cultures
and conventions. It is designed to be simple and light, without too much overhead
while still retaining the old pure HTML/CSS mindset. You create components by
writing HTML/CSS like you used to be. Those components can be reused
and controlled consistently. The PotatoBeans SPA framework also
originally added animation support, using CSS3 animations, which did not exist if we were
to use pure Dart. Animation was the main driver of why this framework was born
out of pure Dart for web. Animations were great using jQuery but not available in Dart dart:html
.
jQuery shaped the web. Many of jQuery features were eventually absorbed into the body of W3C specifications and become native in many frameworks. Animations unfortunately were not part of that. An effort to create reliable animation exists, but so far it's only available in Chrome, using WebAnimation API. That is why we create animations by using wrapper codes of CSS3 transitions. It feels like a hack, it is actually, but it's reliable and works in almost all modern browsers.
Future Improvements #
- A generic implementation/architecture of a controller, which exchanges and controls a view based on a model (like a router)
- Have
UrlComponentRouter
also to also/
URL (the root) - Better and cleaner event handling architecture