JavaScript
The template's JavaScript is plain, modern JavaScript — no jQuery, no framework. Every widget follows one small pattern, and the widgets tell your code what happened through events.
How it fits together
js/app.jsis the core. It creates the globalOdinobject and runs on every page, after the Bootstrap bundle and before any other template script.- Each widget is a component: a class that attaches to every element matching a CSS selector —
select[data-ts],[data-table],#sidebar, … - When the page has loaded,
app.jsfinds those elements and creates one component per element. You don't call anything to start them; adding the markup is enough. - The other files in
js/each add components for one feature. See the JavaScript files table for what each needs.
The component pattern
A component extends Odin.Component, says which elements it attaches to, and does its setup in init(). Then it's registered under a name:
// <button data-copy="#apiKey">Copy</button> copies the value of #apiKey
(function (Odin) {
'use strict';
class CopyButton extends Odin.Component {
static selector = '[data-copy]'; // elements it attaches to
static defaults = { doneText: 'Copied' }; // this.options starts from these
init() {
this.target = document.querySelector(this.el.dataset.copy);
if (!this.target) return;
this.label = this.el.textContent;
this.on(this.el, 'click', () => this.copy());
}
async copy() {
await navigator.clipboard.writeText(this.target.value);
this.el.textContent = this.data('doneText', this.options.doneText); // data-done-text="…"
setTimeout(() => { this.el.textContent = this.label; }, 1500);
this.emit('copied', { value: this.target.value }); // fires "odin:copied"
}
}
Odin.register('copyButton', CopyButton);
})(window.Odin);
Load your file after js/app.js. Every [data-copy] on the page now works — including ones added later, once you call Odin.init() on them (see Content added later).
What a component has
| Member | What it is |
|---|---|
static selector | The CSS selector of the elements the component attaches to |
static defaults | Default options; this.options starts as a copy of them |
this.el | The element this instance belongs to |
init() | Runs once when the instance is created — do your setup here |
this.$(selector), this.$$(selector) | Find one / all elements inside this.el ($$ returns an array) |
this.on(target, event, handler) | addEventListener; with null as target it listens on this.el |
this.data(key, fallback) | Reads a data-* attribute of this.el ('doneText' reads data-done-text). "true" / "false" become booleans and numbers become numbers; empty or missing gives fallback |
this.emit(name, detail) | Fires a bubbling odin:name event from this.el, with detail |
Components started automatically get no options of their own — this.options is just the defaults. Read per-element settings with this.data(), as the template's components do.
The Odin object
| Method | What it does |
|---|---|
Odin.register(name, Class) | Registers a component. If the page has already loaded, it starts on the page right away |
Odin.init(root) | Starts every registered component inside root (default: the whole page). Elements that already have an instance are skipped, so it's safe to call twice |
Odin.init(root, name) | The same, for one component only |
Odin.get(element, name) | The instance of component name on element, or undefined |
Odin.create(name, element, options) | Creates one instance by hand, with your own options |
Odin.delegate(event, selector, handler) | One listener on the document for all matching elements, now and later. The handler gets (event, matchedElement) |
Odin.dismissible(type, selector, close) | Makes [data-dismiss="type"] buttons close their nearest selector (default: remove it) |
Odin.$(selector, root), Odin.$$(selector, root) | querySelector / querySelectorAll as an array |
Odin.components | A Map of registered names → classes |
// open the mobile menu from your own code
Odin.get(document.getElementById('sidebar'), 'sidebar').open();
// one handler for every "Archive" button, including rows added later
Odin.delegate('click', '[data-archive]', (e, button) => {
archive(button.dataset.archive);
});
// [data-dismiss="note"] buttons hide their .note-card instead of removing it
Odin.dismissible('note', '.note-card', card => { card.hidden = true; });
Content added later
Components start once, when the page loads. HTML you insert afterwards — rows loaded with fetch, a form in a modal — needs one call to bring its widgets to life:
const html = await (await fetch('/admin/pages/42/edit-form')).text();
const box = document.getElementById('editPanel');
box.innerHTML = html;
Odin.init(box); // starts selects, date pickers, tooltips, … inside box
Events
Components report what the user did with odin:* events. They bubble, so you can listen on the element or on any parent — even document. The data is in event.detail. These are the places to call your server:
| Event | Fired by | event.detail |
|---|---|---|
odin:sidebar-open, odin:sidebar-close | #sidebar on small screens | — |
odin:form-valid, odin:form-invalid | form.needs-validation on submit | — |
odin:confirmed | A [data-confirm] element after "Yes" | — |
odin:drop | A Dragula list ([data-plugin="dragula"]) | { item, target, source, order } — order: the data-ids of the target list |
odin:chart-ready | A chart ([data-plugin="chartjs"], [data-plugin="apexcharts"]) | { chart } — the Chart.js or ApexCharts instance |
odin:tree-select, -check, -toggle, -move, -create, -rename, -delete | A tree view ([data-tree-view]) | { node } + selected, checked, opened, parent, index, from or old |
odin:support-period | The support dashboard, after the period changes | { days } |
odin:finance-filter | The finance dashboard, after the period or business unit changes | { period, unit } |
odin:sales-country | The sales dashboard, when a country is chosen (map, table or list) | { country } |
odin:tree-order-save | The pages tree table, on "Save order" | { order, previous } — order: [{ id, parent, sort }] |
odin:nav-save | The menu editor, on save | { menu } — the menu as JSON |
odin:task-move | The task board, after a card is dragged | { id, status, sort } |
odin:message-open, odin:message-reply, odin:message-status, odin:message-star, odin:message-delete | The Messages app | { thread } + message (reply), status or starred |
odin:ticket-open, odin:ticket-update, odin:ticket-reply, odin:ticket-create, odin:ticket-bulk | The Support Tickets app | { ticket } + field, value, previous (update), message (reply); bulk: { tickets, field, value } |
odin:task-change | The calendar, after a task is moved or resized | { id, start, due } |
document.addEventListener('odin:task-move', e => {
const { id, status, sort } = e.detail;
fetch(`/admin/tasks/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status, sort })
});
});
The template saves nothing — these events are where your code takes over. The forms, the menu editor and the pages tree only show a "saved" message in the demo.
Ready-made helpers
Some scripts add helpers you can call directly:
| Helper | From | What it does |
|---|---|---|
Odin.loadData(el, name) | data-source.js | Loads el's data-source (URL or #id), falls back to data-fallback, shows a notice on fallback or error; returns a Promise |
Odin.toast({ type, title, text, timeout, position, … }), Odin.toast.success(text, title), Odin.toast.clear() | alerts.js | Shows a toast — see Toasts |
Odin.Dialog.confirm({ title, text, confirmText, danger }) | modals.js | Asks a question; resolves to true or false |
Odin.Dialog.prompt({ title, label, value, validate }) | modals.js | Asks for a value; resolves to the text or null |
Odin.Dialog.success(title, text), .error(), .warning(), .info() | modals.js | A themed message box |
Odin.modal('#id') | modals.js | The Bootstrap modal instance — .show(), .hide() |
if (await Odin.Dialog.confirm({ title: 'Delete this page?', text: 'It will be moved to the trash.', confirmText: 'Delete' })) {
await fetch('/admin/pages/42', { method: 'DELETE' });
Odin.toast({ type: 'success', text: 'Page deleted.' });
}
Components in the template
The names to use with Odin.get(element, name):
| Name | Attaches to | File |
|---|---|---|
sidebar | #sidebar | app.js |
submenu | [data-toggle="submenu"] | |
tooltip | [data-bs-toggle="tooltip"] | |
toastTrigger | [data-toast] | alerts.js |
select, datePicker, uploader | select[data-ts], input[data-fp], [data-dropzone] | forms.js |
slugField, charCounter, passwordToggle, indeterminate | [data-slug-source], [maxlength][data-count], [data-toggle-password], input[data-indeterminate] | |
formValidator | form.needs-validation | |
authForm | form[data-auth-form] | auth.js |
confirmTrigger, dialogTrigger, modalFocus | [data-confirm], [data-dialog], .modal | modals.js |
dataTable | [data-table] | tables.js |
treeTable | [data-tree-table] | tree-table.js |
navEditor | [data-nav-editor] | navigation.js |
supportDashboard | [data-support-dashboard] | support-dashboard.js |
financeDashboard | [data-finance-dashboard] | finance-dashboard.js |
salesDashboard | [data-sales-dashboard] | sales-dashboard.js |
treeView | [data-tree-view] | tree-view.js |
dragBoard, dragCount | [data-plugin="dragula"], [data-drag-count] | dragula.js |
chartjs | [data-plugin="chartjs"] | chartjs.js |
apexcharts | [data-plugin="apexcharts"] | apexcharts.js |
messages | [data-messages] | messages.js |
ticketDesk | [data-ticket-desk] | support-tickets.js |
fileManager | [data-file-manager] | file-manager.js |
taskCalendar | [data-task-calendar] | calendar.js |
taskBoard | [data-task-board] | kanban.js |