01 — Data UI
Combobox
Autocomplete combobox (local JSON or remote ?q=).
Local options via data-combobox-local
<span class="label">City</span>
<div data-combobox
data-combobox-name="city"
data-combobox-local='[
{"value":"at","label":"Athens"},
{"value":"be","label":"Berlin"},
{"value":"ca","label":"Cairo"},
{"value":"dk","label":"Denver"},
{"value":"es","label":"Edinburgh"}
]'
data-combobox-placeholder="Type to filter…"></div>
Remote: Downstage.combobox.mount + async
fetch (demo uses JSONPlaceholder users).
<div id="demo-combobox-api"></div>
Downstage.combobox.mount('#demo-combobox-api', {
minChars: 1,
placeholder: 'Type a name…',
name: 'userId',
fetchOptions: function (query) {
return fetch('https://jsonplaceholder.typicode.com/users')
.then(function (r) { return r.json(); })
.then(function (all) {
var q = String(query).toLowerCase();
return all
.filter(function (u) {
return !q || u.name.toLowerCase().indexOf(q) !== -1;
})
.slice(0, 8)
.map(function (u) {
return { value: String(u.id), label: u.name + ' — ' + u.email };
});
});
}
});
02 — Data UI
Search
API-backed suggestions; same pattern as the combobox with a search icon.
In this demo the fetch URL is set in script (JSONPlaceholder). Listen for
combobox-change on the root to react to selection.
<div id="demo-search-autocomplete"></div>
Downstage.searchAutocomplete.mount('#demo-search-autocomplete', {
minChars: 1,
placeholder: 'Search users…',
fetchSuggestions: function (query) {
return fetch('https://jsonplaceholder.typicode.com/users')
.then(function (r) { return r.json(); })
.then(function (all) {
var q = String(query).toLowerCase();
return all
.filter(function (u) {
return !q || u.name.toLowerCase().indexOf(q) !== -1;
})
.slice(0, 10)
.map(function (u) {
return { value: String(u.id), label: u.name + ' (' + u.email + ')' };
});
});
}
});
03 — Data UI
Input Autocomplete
Single-value autocomplete that searches via GET (API or custom function)
and stores the selected value in a hidden input while displaying the
label. Two creation modes are available when no match is found:
inline (one-click POST of the typed text) or modal (multi-field form
before the POST).
Inline create
Demo: searches JSONPlaceholder users by name. If nothing matches, a "Create …" row appears and sends a POST with the typed text.
AuthorListen for input-autocomplete-change on the root.
The event detail contains { value, label, created }.
<span class="label">Author</span>
<div id="demo-input-autocomplete"></div>
Downstage.inputAutocomplete.mount('#demo-input-autocomplete', {
name: 'authorId',
placeholder: 'Search or create an author…',
minChars: 1,
fetchOptions: function (query) {
return fetch('https://jsonplaceholder.typicode.com/users')
.then(function (r) { return r.json(); })
.then(function (all) {
var q = String(query).toLowerCase();
return all
.filter(function (u) {
return !q || u.name.toLowerCase().indexOf(q) !== -1;
})
.slice(0, 8)
.map(function (u) {
return { value: String(u.id), label: u.name };
});
});
},
createOption: function (text) {
return fetch('https://jsonplaceholder.typicode.com/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: text })
})
.then(function (r) { return r.json(); })
.then(function (u) {
return { value: String(u.id), label: text };
});
}
});
// URL shorthand — component builds fetch/POST automatically
Downstage.inputAutocomplete.mount('#el', {
name: 'authorId',
placeholder: 'Search or create…',
fetchUrl: '/api/authors', // GET /api/authors?q=…
createUrl: '/api/authors', // POST /api/authors { "name": "…" }
createPayloadKey: 'name', // key used in the POST body (default)
createMethod: 'POST' // HTTP method (default)
});
<!-- Auto-initialised by Downstage.inputAutocomplete.init() -->
<div data-input-autocomplete
data-input-autocomplete-name="authorId"
data-input-autocomplete-placeholder="Search or create…"
data-input-autocomplete-fetch="/api/authors"
data-input-autocomplete-create="/api/authors"
data-input-autocomplete-create-key="name"
data-input-autocomplete-create-method="POST"
data-input-autocomplete-min-chars="1"
data-input-autocomplete-debounce="300"></div>
Create via modal
When you need more than just a name to create a new record, pass
createFields and the component opens a modal with N form fields before
sending the POST. The dropdown always shows a
"+ Create new…" option at the bottom; clicking it opens the modal, and the typed query
pre-fills the first field automatically.
Demo: same user search, but creation goes through a modal with name, email and role fields.
Author (modal create)<span class="label">Author (modal create)</span>
<div id="demo-iac-modal"></div>
Downstage.inputAutocomplete.mount('#demo-iac-modal', {
name: 'authorId',
placeholder: 'Search or create an author…',
minChars: 1,
// Search — same as inline example
fetchOptions: function (query) {
return fetch('https://jsonplaceholder.typicode.com/users')
.then(function (r) { return r.json(); })
.then(function (all) {
var q = String(query).toLowerCase();
return all
.filter(function (u) {
return !q || u.name.toLowerCase().indexOf(q) !== -1;
})
.slice(0, 8)
.map(function (u) {
return { value: String(u.id), label: u.name };
});
});
},
// Modal configuration
createTitle: 'New Author',
createFields: [
{ key: 'name', label: 'Full name', required: true,
placeholder: 'Jane Doe' },
{ key: 'email', label: 'E-mail', required: true,
type: 'email', placeholder: 'jane@example.com' },
{ key: 'role', label: 'Role', type: 'select',
options: [
{ value: 'author', label: 'Author' },
{ value: 'editor', label: 'Editor' },
{ value: 'reviewer', label: 'Reviewer' }
]
}
],
// Receives the full form data object
createOption: function (data) {
// data = { name: "Jane Doe", email: "jane@…", role: "editor" }
return fetch('https://jsonplaceholder.typicode.com/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(function (r) { return r.json(); })
.then(function (u) {
return { value: String(u.id), label: data.name };
});
}
});
// When createFields is present, createUrl POSTs the full
// form data object instead of just { [payloadKey]: text }
Downstage.inputAutocomplete.mount('#el', {
fetchUrl: '/api/authors',
createUrl: '/api/authors',
createTitle: 'New Author',
createFields: [
{ key: 'name', label: 'Name', required: true },
{ key: 'email', label: 'Email', required: true, type: 'email' },
{ key: 'phone', label: 'Phone', placeholder: '+1 …' }
]
});
Mount options
Downstage.inputAutocomplete.mount(el, options)
| Option | Type | Default | Description |
|---|---|---|---|
name |
string | "inputAutocomplete" |
Name attribute of the hidden <input> that stores the selected
value. |
placeholder |
string | "" |
Placeholder text for the search field. |
id |
string | auto | Custom id for the visible input. Auto-generated if omitted. |
value |
any | — | Initial hidden-input value (pre-select). |
initialLabel |
string | — | Initial display text matching the pre-selected value. |
minChars |
number | 1 |
Minimum characters before search fires. |
debounceMs |
number | 300 |
Debounce delay (ms) on the input event. |
| Search | |||
fetchOptions |
function | — | Custom search: function(query) → Promise<[{ value, label }]>.
|
fetchUrl |
string | — | API endpoint; the component appends ?q=… and expects a JSON array
(or { results } / { items } /
{ data }).
|
| Inline create (simple) | |||
createOption |
function | — | Without createFields:
function(text) → Promise<{ value, label }>.With createFields:
function(data) → Promise<{ value, label }> where
data is the full form object.
|
createUrl |
string | — | POST endpoint for creation. Auto-generates createOption if not
provided. |
createMethod |
string | "POST" |
HTTP method for createUrl. |
createPayloadKey |
string | "name" |
Key used in the POST body for inline create: { [key]: text }.
Ignored when createFields is set. |
| Modal create (advanced) | |||
createFields |
array | — | Array of field definitions for the creation modal. When set, clicking "Create new…" opens a modal instead of posting inline. See Create field options below. |
createTitle |
string | i18n modalTitle |
Title shown in the modal header. |
Create field options
Each object in the createFields array
accepts:
| Property | Type | Description |
|---|---|---|
key |
string | Required. Property name in the data object sent to createOption / POST body. |
label |
string | Human-readable label. Defaults to key. |
type |
string | Input type: "text" (default), "email",
"number", "textarea",
"select".
|
required |
boolean | If true, the field is validated before submit. |
placeholder |
string | Input placeholder text. |
default |
any | Default value when the modal opens. |
options |
array | For type: "select". Array of
{ value, label } objects or plain strings.
|
step |
number | Step increment for type: "number" inputs. |
Data attributes
For declarative setup (auto-initialised by
Downstage.inputAutocomplete.init()):
| Attribute | Maps to |
|---|---|
data-input-autocomplete |
Activates the component on the element. |
data-input-autocomplete-name |
name |
data-input-autocomplete-placeholder |
placeholder |
data-input-autocomplete-fetch |
fetchUrl |
data-input-autocomplete-create |
createUrl |
data-input-autocomplete-create-key |
createPayloadKey |
data-input-autocomplete-create-method |
createMethod |
data-input-autocomplete-min-chars |
minChars |
data-input-autocomplete-debounce |
debounceMs |
Programmatic API
Downstage.inputAutocomplete.mount() returns
an object:
| Property / Method | Description |
|---|---|
root |
The container DOM element. |
input |
The visible text <input>. |
hidden |
The hidden <input> that stores the value. |
getValue() |
Returns the current hidden input value. |
setValue(val, label) |
Programmatically sets the value and display label. |
clear() |
Clears both the hidden value and visible text. |
destroy() |
Removes all DOM and classes; restores the root element. |
Events
The component dispatches a bubbling
CustomEvent on the root element:
| Event | detail | Fires when |
|---|---|---|
input-autocomplete-change |
{ value, label, created } |
A value is selected from the dropdown or a new item is created (inline or modal).
created is true when the item was
just created.
|
document.getElementById('my-autocomplete')
.addEventListener('input-autocomplete-change', function (e) {
console.log('Selected:', e.detail.value, e.detail.label);
if (e.detail.created) {
console.log('This item was just created!');
}
});
04 — Data UI
Tag Input
Multi-value tag field with GET autocomplete and optional
POST to create new tags on the fly. Selected tags appear as removable pills;
the hidden input stores a JSON array of {value, label} objects.
Demo: search JSONPlaceholder users as tags. Type something that doesn't match to create a new one.
SkillsListen for tag-input-change on the root.
event.detail.tags is the full array. Press Backspace on an empty
field to remove the last tag.
<span class="label">Skills</span>
<div id="demo-tag-input"></div>
Downstage.tagInput.mount('#demo-tag-input', {
name: 'skills',
placeholder: 'Add a skill…',
minChars: 1,
fetchOptions: function (query) {
return fetch('https://jsonplaceholder.typicode.com/users')
.then(function (r) { return r.json(); })
.then(function (all) {
var q = String(query).toLowerCase();
return all
.filter(function (u) {
return !q || u.name.toLowerCase().indexOf(q) !== -1;
})
.slice(0, 8)
.map(function (u) {
return { value: String(u.id), label: u.name };
});
});
},
createOption: function (text) {
return fetch('https://jsonplaceholder.typicode.com/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: text })
})
.then(function (r) { return r.json(); })
.then(function (u) {
return { value: String(u.id), label: text };
});
}
});
// Or use data attributes:
// <div data-tag-input
// data-tag-input-name="skills"
// data-tag-input-fetch="/api/skills"
// data-tag-input-create="/api/skills"
// data-tag-input-create-key="name"
// data-tag-input-placeholder="Add a skill…"
// data-tag-input-max="10"></div>
//
// Programmatic API:
// var tags = Downstage.tagInput.mount(…);
// tags.getTags(); // [{value, label}, …]
// tags.addTag('5', 'Go');
// tags.removeTag('5');
// tags.clear();
05 — Data UI
Kanban
Board data is loaded with GET
../demo/kanban-board.json (same origin). Moving a card sends a
POST
with
JSON (cardId, fromColumnId,
toColumnId, toIndex) to a demo endpoint; swap
fetchUrl / moveUrl for your API. On failed
move
the
card reverts.
<div data-kanban
data-kanban-fetch-url="../demo/kanban-board.json"
data-kanban-move-url="https://httpbin.org/post"
data-kanban-move-method="POST"
data-kanban-move-credentials="omit"></div>
Downstage.kanban.mount('#board', {
fetchUrl: '../demo/kanban-board.json',
moveUrl: 'https://httpbin.org/post',
moveMethod: 'POST',
moveCredentials: 'omit'
});
| Attribute | Maps to | Description |
|---|---|---|
data-kanban-fetch-url |
fetchUrl |
GET JSON board: { columns: [{ id, title, cards: [{ id, title, meta }] }] } |
data-kanban-fetch-credentials |
fetchCredentials |
same-origin (default), omit,
or include for the board request. |
data-kanban-move-url |
moveUrl |
POST endpoint for card moves (JSON body with cardId, fromColumnId, toColumnId, toIndex). |
data-kanban-move-method |
moveMethod |
HTTP method (default POST). |
data-kanban-move-credentials |
moveCredentials |
Fetch credentials for the move request; auto omit for cross-origin if unset. |
Alternatively pass fetchBoard /
moveCard
functions in mount(el, options) instead of URLs.
06 — Data UI
Data table
Local: sort, filter, pagination in the browser (data-data-table-demo).
<div data-data-table data-data-table-demo></div>
Downstage.dataTable.mount('#my-table', {
mode: 'local',
pageSize: 5,
columns: [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
{ key: 'role', label: 'Role' }
],
rows: [
{ name: 'Ada Lovelace', email: 'ada@example.com', role: 'Admin' },
{ name: 'Alan Turing', email: 'alan@example.com', role: 'Editor' },
{ name: 'Grace Hopper', email: 'grace@example.com', role: 'Editor' },
{ name: 'Edsger Dijkstra', email: 'edsger@example.com', role: 'Viewer' },
{ name: 'Margaret Hamilton', email: 'margaret@example.com', role: 'Admin' },
{ name: 'Ken Thompson', email: 'ken@example.com', role: 'Viewer' }
]
});
Remote-style: one HTTP request, then client-side paging/sort/filter on the
result set (illustrates fetchRemote; your server can return
{ rows, total } per page).
<div id="demo-data-table-remote"></div>
Downstage.dataTable.mount('#demo-data-table-remote', {
mode: 'remote',
pageSize: 4,
searchPlaceholder: 'Filter loaded users…',
columns: [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
{ key: 'company', label: 'Company' }
],
fetchRemote: function (params) {
// params: { page, pageSize, q, sortKey, sortDir }
return fetch('https://jsonplaceholder.typicode.com/users')
.then(function (r) { return r.json(); })
.then(function (all) {
var rows = all.map(function (u) {
return {
name: u.name,
email: u.email,
company: u.company.name
};
});
var total = rows.length;
var start = (params.page - 1) * params.pageSize;
return { rows: rows.slice(start, start + params.pageSize), total: total };
});
}
});
07 — Data UI
Order List
Search-to-add item list — ideal for invoices,
orders, and quote builders.
Items are selected via an autocomplete search and added to a configurable table.
Columns can map to object properties, render quantity +/− controls, or use
computed values (e.g. unit cost × qty). Adding a duplicate increments quantity.
Switch between column sort (click headers) and manual sort
(drag handle reorder). Output is a JSON array with sort_order on every item.
Optionally, a "Create new item" modal lets users add items not yet in the catalog.
Live demo
Search a product, click to add (duplicates auto-increment qty). Toggle Manual sort to drag-reorder rows. Type a term with no match to see "Create new item…".
<div data-order-list data-order-list-demo></div>
var products = [
{ value: { codice: 'P001', nome: 'Widget Pro', unita: 'pz', costo: 12.50 }, label: 'P001 — Widget Pro' },
{ value: { codice: 'P002', nome: 'Gadget Lite', unita: 'pz', costo: 8.00 }, label: 'P002 — Gadget Lite' },
// …more items
];
Downstage.orderList.mount('[data-order-list-demo]', {
name: 'invoice_items',
placeholder: 'Search products…',
itemKey: 'codice',
options: products,
columns: [
{ key: 'codice', label: 'Code', from: 'value.codice' },
{ key: 'nome', label: 'Product', from: 'value.nome' },
{ key: 'qty', label: 'Qty', type: 'qty' },
{ key: 'unita', label: 'Unit', from: 'value.unita' },
{ key: 'costo', label: 'Unit price', from: 'value.costo' },
{ key: 'totale', label: 'Total', compute: function (item) {
return ((item.qty || 1) * (item.value.costo || 0)).toFixed(2);
}
},
],
createTitle: 'New product',
createFields: [
{ key: 'codice', label: 'Product code', type: 'text', required: true },
{ key: 'nome', label: 'Product name', type: 'text', required: true },
{ key: 'unita', label: 'Unit', type: 'select', options: ['pz','h','yr','ea'] },
{ key: 'costo', label: 'Unit price', type: 'number', required: true, step: '0.01' },
],
createOption: function (data) {
return fetch('/api/items', { method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}).then(function (r) { return r.json(); });
},
onChange: function (items) {
console.log('Order changed:', items);
}
});
// Search items from your API (GET /api/products?q=query)
Downstage.orderList.mount('#my-order', {
name: 'order_items',
fetchUrl: '/api/products', // automatic GET ?q=…
// OR custom fetch function:
// fetchOptions: function (query) {
// return fetch('/api/products?search=' + encodeURIComponent(query))
// .then(function (r) { return r.json(); })
// .then(function (data) {
// return data.map(function (p) {
// return {
// value: { id: p.id, name: p.name, unit: p.unit, price: p.price },
// label: p.id + ' — ' + p.name
// };
// });
// });
// },
itemKey: 'id',
columns: [
{ key: 'id', label: 'ID', from: 'value.id' },
{ key: 'name', label: 'Product', from: 'value.name' },
{ key: 'qty', label: 'Qty', type: 'qty' },
{ key: 'price', label: 'Unit price', from: 'value.price' },
{ key: 'total', label: 'Total', compute: function (item) {
return ((item.qty || 1) * (item.value.price || 0)).toFixed(2);
}
},
],
});
Mount options
| Option | Type | Default | Description |
|---|---|---|---|
name |
string | "order_items" |
Name of the hidden <input> that holds the JSON output (for
form submission). |
columns |
array | [] |
Column definitions — see Column options below. |
options |
array | [] |
Local item array: [{ value, label }]. value can be a string or object. |
fetchUrl |
string | — | API endpoint; component appends ?q=… and expects a JSON array (or
{ results } / { items }). |
fetchOptions |
function | — | Custom search: function(query) → Promise<[{ value, label }]>.
|
itemKey |
string | — | Dot-path into value used for duplicate detection. If omitted, the
entire value is compared. |
items |
array | [] |
Pre-populated rows: [{ value, qty?, sort_order? }]. |
sortMode |
string | "column" |
Initial sort mode: "column" (click headers) or "manual" (drag reorder). |
placeholder |
string | i18n key | Search input placeholder text. |
minChars |
number | 0 local, 1 remote | Minimum characters before search runs. |
debounceMs |
number | 250 | Debounce delay (ms) for search input. |
onChange |
function | — | Callback: function(items) — called on every add / remove / qty
change / reorder. |
createTitle |
string | i18n key | Modal heading when creating a new item. |
createFields |
array | — | Field definitions for the create modal — see Create field options below. Required to enable creation. |
createOption |
function | — | Async callback: function(data) → Promise<{ value, label }>.
Receives form data, returns the new item. |
createUrl |
string | — | Shorthand: POST JSON to this URL instead of providing createOption.
|
Column options
Each entry in the columns array configures
one table column.
| Property | Type | Description |
|---|---|---|
key |
string | Unique column identifier (used internally and as sort key). |
label |
string | Header text. Falls back to key. |
from |
string | Dot-notation path resolved on the row item, e.g. "value.codice". |
type |
string | Set to "qty" to render +/− quantity controls. Only one qty column
per list. |
compute |
function | function(item) → value — computed display value (e.g. qty ×
price). Not sortable by header click. |
render |
function | function(cellValue, item) → html — custom HTML for the cell. |
sortable |
boolean | Set to false to disable column-sort on this header. Default true (auto-disabled for qty / computed). |
Create field options
Each entry in createFields generates one
form field inside the create modal.
| Property | Type | Description |
|---|---|---|
key |
string | Property name in the submitted data object. |
label |
string | Field label text. |
type |
string | "text" (default), "number", "select", or "textarea". |
options |
array | For type: "select": array of strings or { value, label } objects. |
required |
boolean | If true, submission is blocked when field is empty. |
placeholder |
string | Input placeholder text. |
default |
any | Default value when the modal opens. |
step |
string | Step attribute for number inputs (e.g. "0.01"). |
Programmatic API
Downstage.orderList.mount() returns an
object:
| Method / property | Description |
|---|---|
root |
The mounted DOM element. |
getItems() |
Returns the current items array: [{ value, qty, sort_order }]. |
setItems(arr) |
Replaces all items and re-renders. |
addItem(value) |
Adds an item (or increments qty if duplicate). |
removeItem(index) |
Removes the item at the given index. |
refresh() |
Re-renders the table. |
Events
The component dispatches a bubbling order-list-change
custom event on the root element. Access data via event.detail.items.
JSON output format
[
{
"value": { "codice": "P001", "nome": "Widget Pro", "unita": "pz", "costo": 12.50 },
"qty": 3,
"sort_order": 0
},
{
"value": { "codice": "P003", "nome": "Service Pack", "unita": "h", "costo": 45.00 },
"qty": 1,
"sort_order": 1
}
]
08 — Data UI
Map
Interactive markers loaded with GET from your API.
The demo uses Leaflet (open source) with
CARTO Voyager tiles over OpenStreetMap data — no Google Maps key or
billing;
attribution is shown on the map.
JSON shape: { "markers": [{ "lat", "lng", "title", "description", "color" }] }
— extend with your own fields and read them in the popup.
Demo file: ../demo/map-pins.json. Replace the URL with
GET /api/locations (same JSON).
<!-- Leaflet CSS + JS from CDN; then a container -->
<div id="my-map" class="ds-map-demo ds-map-demo--tall"></div>
fetch('/api/locations')
.then(function (r) { return r.json(); })
.then(function (data) {
var markers = data.markers || data;
var map = L.map('my-map').setView([45.5, 9.2], 6);
L.tileLayer(
'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
{
attribution: '© OSM © CARTO',
subdomains: 'abcd',
maxZoom: 20
}
).addTo(map);
var group = L.featureGroup();
markers.forEach(function (m) {
var icon = L.divIcon({
className: 'ds-leaflet-marker-wrap',
html: '<div class="ds-leaflet-marker-pin" style="--pin-color:' + (m.color || '#5a6b5d') + '"></div>',
iconSize: [32, 40],
iconAnchor: [16, 40]
});
L.marker([m.lat, m.lng], { icon: icon })
.bindPopup('<strong>' + m.title + '</strong><br>' + m.description)
.addTo(group);
});
map.fitBounds(group.getBounds().pad(0.15));
});
Include Leaflet before your script. Map height and card chrome come
from
downstage.css / demo.css (.ds-map-demo,
.ds-map-demo--tall). Custom markers use .ds-leaflet-marker-pin
with --pin-color.
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
09 — Data UI
Calendar
Full calendar with month, week,
day and timeline (Gantt / resource) views.
Events and resources are fetched from your API via
fetchUrl or a custom
fetchEvents function.
Month / Week / Day
Default month view — switch to week, day or timeline with
the
toolbar. Events come from
../demo/calendar-events.json.
<div data-calendar
data-calendar-fetch-url="/api/events"
data-calendar-default-view="month"></div>
Downstage.calendar.mount('#my-calendar', {
fetchUrl: '/api/events',
defaultView: 'month', // month | week | day | timeline
timelineHours: [8, 18],
onEventClick: function (ev) {
console.log('Clicked:', ev);
}
});
// API should return JSON:
// {
// "resources": [
// { "id": "room-a", "title": "Room A" },
// { "id": "dev-1", "title": "Alice" }
// ],
// "events": [
// {
// "id": 1,
// "title": "Sprint planning",
// "start": "2026-04-09T09:00",
// "end": "2026-04-09T10:30",
// "resourceId": "room-a",
// "color": "#5a6b5d"
// }
// ]
// }
//
// Query params sent: ?start=...&end=...&view=month
On the root element, any data-calendar-foo-bar
becomes the mount option fooBar
(dataset → camelCase).
| Attribute | Option | Description |
|---|---|---|
data-calendar-fetch-url |
fetchUrl |
GET endpoint; start, end,
view are sent as query params. |
data-calendar-fetch-credentials |
fetchCredentials |
same-origin (default), omit,
or include. |
data-calendar-default-view |
defaultView |
month | week | day | timeline |
data-calendar-demo |
— | Internal flag for the documentation demo (loads bundled JSON). |
For full control, use Downstage.calendar.mount(el, { fetchEvents: function (params) { … } }).
Timeline (Gantt)
Start directly in timeline view to see resources on rows and days on columns. Each event is a horizontal bar across the resource track.
<div data-calendar
data-calendar-fetch-url="/api/events"
data-calendar-default-view="timeline"></div>
Downstage.calendar.mount('#gantt', {
fetchUrl: '/api/events',
defaultView: 'timeline',
timelineHours: [8, 18],
// weekStart: 1, // 0 = Sunday, 1 = Monday (default)
onEventClick: function (ev) {
console.log('Event:', ev.title, ev.resourceId);
}
});
// Programmatic API:
// var cal = Downstage.calendar.mount(…);
// cal.setView('week');
// cal.setDate('2026-05-01');
// cal.refresh();
// cal.getView(); // 'week'
// cal.getDate(); // Date object
10 — Data UI
Statistics & charts
KPI cards use existing .stat-card / .dashboard-grid styles.
Classic bar, line, and doughnut (pie) charts are rendered with
Chart.js from a JSON
payload — the same
shape you would return from GET /api/metrics. A second line chart polls a
live public API (BTC/USDT spot from Binance every 4s) so you can compare static batch data
vs. real-time polling. Colors follow CSS variables so they track light/dark theme.
fetch
{
"kpis": [
{ "label": "Active users", "value": "1,284", "change": "+3%", "deltaClass": "up" },
{ "label": "Churn", "value": "2.1%", "change": "−0.4pp", "deltaClass": "up" },
{ "label": "Latency p95", "value": "142ms", "change": "+8ms", "deltaClass": "down" }
],
"bar": {
"datasetLabel": "Sign-ups",
"labels": ["Jan", "Feb"],
"values": [12, 19]
},
"line": {
"datasetLabel": "Sessions",
"labels": ["W1", "W2"],
"values": [420, 510]
},
"pie": {
"labels": ["Direct", "Organic"],
"values": [60, 40]
}
}
fetch('/api/metrics', { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (data) {
// data.kpis → render .stat-card nodes
// data.bar / data.line → new Chart(canvas, { type: 'bar' | 'line', … })
// data.pie → new Chart(canvas, { type: 'doughnut', … })
});
This page loads demo/stats-api.json next to
demo.js. Add chart.js before your
bundle; the demo uses the UMD build.
Poll your metric endpoint on a timer, push the new sample into arrays,
cap the window, then chart.update('none'). The demo calls Binance’s
public ticker (CORS allowed); in production, call your own API or a backend proxy.
var labels = [], values = [], maxPoints = 18;
var chart = new Chart(canvas, { /* same line options as static chart */ });
function poll() {
fetch('/api/metrics/live', { cache: 'no-store' })
.then(function (r) { return r.json(); })
.then(function (d) {
var t = new Date().toLocaleTimeString();
labels.push(t);
values.push(d.value);
if (labels.length > maxPoints) { labels.shift(); values.shift(); }
chart.data.labels = labels;
chart.data.datasets[0].data = values;
chart.update('none');
});
}
poll();
setInterval(poll, 4000);
<div class="dashboard-grid">
<article class="stat-card">
<span class="stat-label">Active users</span>
<span class="stat-value">1,284</span>
<span class="stat-change is-up">+3%</span>
</article>
</div>
<div class="chart-card">
<div class="chart-header">
<div class="chart-title">Title</div>
<div class="chart-subtitle">Subtitle</div>
</div>
<div class="chart-canvas chart-canvas--chartjs">
<canvas id="my-chart"></canvas>
</div>
</div>