01 — Data UI

Combobox

Autocomplete combobox (local JSON or remote ?q=).

Local options via data-combobox-local

City
<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).

User
<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 };
          });
      });
  }
});

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.

Author

Listen 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.

Skills

Listen 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: '&copy; OSM &copy; 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.

Bar — monthly sign-ups
Category scale
Doughnut — traffic mix
Share of sessions
Line — sessions over time
Trend / time series
Line — live (real-time)
Same style as above · data from repeated 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>