diff --git a/.ai/README.md b/.ai/README.md new file mode 100644 index 0000000..d21038a --- /dev/null +++ b/.ai/README.md @@ -0,0 +1,33 @@ +# Treibstoff — LLM Instruction Files + +This directory contains self-contained instruction files for LLM-assisted +development with treibstoff. Each file can be used as prompt input without +needing additional context. + +## Usage + +Copy the content of the relevant file(s) into your LLM prompt to provide +context for code generation, review, or explanation tasks. + +For general understanding, start with `overview.md`. For specific tasks, +pick the topic file that matches your need. + +## Files + +| File | Description | +|------|-------------| +| [overview.md](overview.md) | Architecture overview — module map, inheritance hierarchy, all public API members | +| [create-widget.md](create-widget.md) | How to create custom widgets — Widget, HTMLWidget, SVGContext, lifecycle | +| [property-binding.md](property-binding.md) | Reactive property system — all 9 property types, auto-handlers, cascading | +| [event-handling.md](event-handling.md) | Events, listeners, keyboard state — on/off/trigger, create_listener, KeyState | +| [drag-and-drop.md](drag-and-drop.md) | Native HTML5 DnD — DnD class, cross-instance coordination, evt.source | +| [motion-tracking.md](motion-tracking.md) | Drag, resize, selection — Motion class, scope variants, down/move/up | +| [template-parsing.md](template-parsing.md) | Template compilation — compile_template, t-elem, t-prop, t-val, t-type | +| [ssr-integration.md](ssr-integration.md) | SSR via HTML attributes — all ajax:* attributes, 7 patterns | +| [ssr-programmatic.md](ssr-programmatic.md) | Programmatic Ajax API — ajax.action, ajax.trigger, ajax.overlay, ajax.register | +| [build-forms.md](build-forms.md) | Form building — Form, FormInput, FormField, FormSelect, validation | +| [overlays-dialogs.md](overlays-dialogs.md) | Overlays and dialogs — Overlay, Dialog, Message, show_dialog, show_error | +| [svg-graphics.md](svg-graphics.md) | SVG graphics — SVGContext, svg_elem, svg_attrs, two-layer pattern | +| [http-requests.md](http-requests.md) | HTTP requests — http_request, HTTPRequest, spinner, error handling | +| [websocket-realtime.md](websocket-realtime.md) | WebSocket — Websocket class, events, JSON messaging, heartbeat | +| [testing.md](testing.md) | Writing QUnit tests — test patterns, DOM fixtures, mocks, assertions | diff --git a/.ai/build-forms.md b/.ai/build-forms.md new file mode 100644 index 0000000..52a4df7 --- /dev/null +++ b/.ai/build-forms.md @@ -0,0 +1,351 @@ +# Building Forms + +This guide explains how to build forms with treibstoff's form system, including +validation, remote data fetching, and Ajax submission. + +## Context + +Treibstoff provides a form abstraction layer on top of DOM form elements. The +`Form` class manages form-level operations, while `FormInput`, `FormField`, +`FormSelect`, `FormCheckbox`, and `FormRemoteSelect` wrap individual elements +with reactive getters/setters and event handling. + +## Key API + +| Class/Function | Purpose | +|----------------|---------| +| `ts.Form` | Form container — initialize, lookup, field visibility | +| `ts.FormInput` | Input wrapper — value, disabled state | +| `ts.FormField` | Field wrapper — visibility, error state, reset | +| `ts.FormCheckbox` | Checkbox wrapper — checked state | +| `ts.FormSelect` | Select wrapper — options, clear | +| `ts.FormRemoteSelect` | Select with server-side option fetching | +| `ts.lookup_form_elem` | Find form element by naming convention | + +## Naming Convention + +Form elements are found by ID following this pattern: + +- Form: `#form-{name}` +- Input: `#input-{form.name}-{field.name}` +- Field: `#field-{form.name}-{field.name}` + +```html +
+
+ + +
+
+ + +
+
+``` + +## Pattern 1: Basic Form with Fields + +```javascript +import ts from 'treibstoff'; + +class UserForm extends ts.Form { + constructor(opts) { + super(opts); + this.email = new ts.FormField({ + form: this, + name: 'email', + input: ts.FormInput + }); + this.role = new ts.FormField({ + form: this, + name: 'role', + input: ts.FormSelect + }); + } +} + +// Initialize from DOM context (e.g. in an ajax.register callback) +ts.Form.initialize($('#content'), UserForm, 'user'); + +// Later, look up the form instance +let form = ts.Form.instance('user'); +form.email.input.value = 'user@example.com'; +``` + +## Pattern 2: Checkbox Fields + +```html +
+
+ + +
+
+``` + +```javascript +class SettingsForm extends ts.Form { + constructor(opts) { + super(opts); + this.notifications = new ts.FormField({ + form: this, + name: 'notifications', + input: ts.FormCheckbox + }); + } +} + +// Read/set checkbox state +let form = ts.Form.instance('settings'); +form.notifications.input.checked = true; +let isChecked = form.notifications.input.checked; +``` + +## Pattern 3: Remote Select (Server-Fetched Options) + +```javascript +class ProjectForm extends ts.Form { + constructor(opts) { + super(opts); + this.category = new ts.FormField({ + form: this, + name: 'category', + input: new ts.FormRemoteSelect({ + form: this, + name: 'category', + vocab: '/api/categories.json' + }) + }); + } + + load_categories(filter) { + // Fetches JSON from /api/categories.json?q=filter + // Server must return array of [value, label] pairs + this.category.input.fetch({q: filter}); + } +} +``` + +The server must return a JSON array of `[value, label]` pairs: +```json +[["cat1", "Category 1"], ["cat2", "Category 2"]] +``` + +## Pattern 4: Select with Programmatic Options + +```javascript +let form = ts.Form.instance('project'); +// Set options (array of [value, label] pairs or Option objects) +form.category.input.options = [ + ['opt1', 'Option 1'], + ['opt2', 'Option 2'] +]; + +// Clear all options +form.category.input.clear(); + +// Read current value +let selected = form.category.input.value; +``` + +## Pattern 5: Field Visibility and Error State + +```javascript +let form = ts.Form.instance('user'); + +// Hide a field +form.email.visible = false; +form.email.hidden = true; // equivalent + +// Show a field +form.email.visible = true; + +// Bulk visibility +form.set_field_visibility([form.email, form.role], false); + +// Error state +form.email.has_error = true; // adds 'has-error' class +form.email.has_error = false; // removes it + +// Reset field (clear value, remove error, remove help text) +form.email.reset(); +form.email.reset('default@example.com'); // reset with default value +``` + +## Pattern 6: Change Events + +`FormSelect` and `FormCheckbox` use the `changeListener` mixin, which triggers +`on_change` when the user interacts with the element. + +```javascript +class FilterForm extends ts.Form { + constructor(opts) { + super(opts); + this.status = new ts.FormField({ + form: this, + name: 'status', + input: ts.FormSelect + }); + // Listen for selection changes + this.status.input.on('on_change', function(inst, evt) { + console.log('Status changed to:', inst.value); + }); + } +} +``` + +## Pattern 7: Template-Based Form Inputs + +For forms built with `compile_template`, use `t-prop` attributes to create +`InputProperty` bindings: + +```javascript +class InlineForm extends ts.Widget { + constructor(opts) { + super({parent: opts.parent}); + ts.compile_template(this, ` +
+ + +
+ `, opts.container); + // this.name is now an InputProperty + // this.count is an InputProperty with number extraction + } + + on_name(val) { + console.log('Name changed to:', val); + } + + on_count(val) { + console.log('Count changed to:', val); // val is a number + } +} +``` + +## Pattern 8: Ajax Form Submission + +Forms marked with `class="ajax"` or `ajax:form="true"` are submitted via a +hidden iframe. The server response calls `ts.ajax.form()` to update the DOM. + +```html +
+
+ +
+ +
+``` + +The server-side handler processes the form and returns an HTML page in the +iframe that calls: + +```html + +``` + +## Complete Example + +```html +
+
+ + +
+
+ + +
+
+ + +
+
+``` + +```javascript +class TaskForm extends ts.Form { + constructor(opts) { + super(opts); + this.title = new ts.FormField({ + form: this, name: 'title', input: ts.FormInput + }); + this.priority = new ts.FormField({ + form: this, name: 'priority', input: ts.FormSelect + }); + this.done = new ts.FormField({ + form: this, name: 'done', input: ts.FormCheckbox + }); + + this.priority.input.on('on_change', this.on_priority_change.bind(this)); + } + + on_priority_change(inst, evt) { + if (inst.value === 'high') { + this.title.elem.addClass('text-danger'); + } else { + this.title.elem.removeClass('text-danger'); + } + } + + validate() { + let valid = true; + if (!this.title.input.value.trim()) { + this.title.has_error = true; + valid = false; + } + return valid; + } + + reset_all() { + this.title.reset(); + this.priority.input.value = 'medium'; + this.done.input.checked = false; + } +} + +$(function() { + ts.ajax.register(function(context) { + ts.Form.initialize(context, TaskForm, 'task'); + }, true); +}); +``` + +## Pitfalls + +1. **Element IDs must follow the naming convention** (`#form-{name}`, + `#input-{form}-{field}`, `#field-{form}-{field}`). If elements aren't found, + pass them explicitly via `opts.elem`. + +2. **`FormField` wraps both the field container and the input.** Access the + input via `field.input`. The field itself provides visibility and error state. + +3. **`FormField` accepts an input class or instance.** Pass `ts.FormInput` (class) + and it creates the instance. Pass `new ts.FormRemoteSelect(...)` (instance) + for pre-configured inputs. + +4. **`FormRemoteSelect.fetch()` is async.** The select options are populated + when the HTTP request completes. + +5. **`Form.initialize()` silently returns if the form element is not found** + in the given context. This is by design — forms may not be present on + every page. diff --git a/.ai/create-widget.md b/.ai/create-widget.md new file mode 100644 index 0000000..60d2c4c --- /dev/null +++ b/.ai/create-widget.md @@ -0,0 +1,235 @@ +# Creating Custom Widgets + +This guide explains how to create custom widgets using treibstoff's widget system. + +## Context + +Treibstoff provides a widget hierarchy based on `Widget` → `HTMLWidget` → `SVGContext`. +Widgets support parent/child relationships, ancestor lookup via `acquire()`, and +integrate with the property binding and motion tracking systems. + +## Key API + +| Class | Purpose | +|-------|---------| +| `ts.Widget` | Base widget with parent/child hierarchy and `acquire()` | +| `ts.HTMLWidget` | Widget wrapping a DOM element with CSS properties (x, y, width, height) | +| `ts.SVGContext` | Widget wrapping an SVG element with SVG helper methods | +| `ts.Property` | Observable property — triggers `on_name(val)` on change | +| `ts.compile_template` | Parse HTML template and wire DOM references onto widget | + +## Pattern: Widget Lifecycle + +The standard lifecycle is: **constructor → compile() → bind() → destroy()**. + +```javascript +import ts from 'treibstoff'; + +class MyWidget extends ts.Widget { + constructor(opts) { + // 1. Establish hierarchy + super({parent: opts.parent}); + + // 2. Acquire context from ancestors (e.g. SVGContext) + this.ctx = this.acquire(ts.SVGContext); + + // 3. Create reactive properties + new ts.DataProperty(this, 'x', {val: opts.data.x}); + new ts.Property(this, 'selected', false); + + // 4. Build DOM/SVG + this.compile(); + + // 5. Attach event listeners + this.bind(); + } + + compile() { + this.elem = this.ctx.svg_elem('g', {}, this.ctx.elem); + this.bg = this.ctx.svg_elem('rect', { + x: 0, y: 0, width: 100, height: 50, fill: '#eee' + }, this.elem); + } + + bind() { + // set_scope comes from Motion (inherited by Widget) + this.set_scope(this.elem, this.ctx.elem); + } + + // Auto-called when 'x' property changes + on_x(val) { + this.ctx.svg_attrs(this.elem, { + transform: `translate(${val} 0)` + }); + } + + // Auto-called when 'selected' property changes + on_selected(val) { + this.ctx.svg_attrs(this.bg, { + fill: val ? '#cdf' : '#eee' + }); + } + + destroy() { + this.elem.remove(); + } +} +``` + +## Complete Example: HTML Widget with Template + +```javascript +import ts from 'treibstoff'; + +class Panel extends ts.HTMLWidget { + constructor(opts) { + super({ + parent: opts.parent, + elem: opts.elem // jQuery wrapped DOM element + }); + + // Reactive properties bound to CSS + new ts.CSSProperty(this, 'opacity', {val: 1}); + + // Compile inner template + ts.compile_template(this, ` +
+ + +
+
+ `, opts.elem); + + // After compile_template: + // this.header → DOM reference + // this.title_elem → DOM reference + // this.body → DOM reference + // this.close_btn → ButtonProperty + } + + on_close_click() { + this.opacity = 0; + this.trigger('on_close'); + } +} + +// Usage: +let panel = new Panel({ + parent: app, + elem: $('#my-panel') +}); +panel.on('on_close', function(inst) { + console.log('Panel closed'); +}); +``` + +## Complete Example: SVG Widget with Drag + +```javascript +import ts from 'treibstoff'; + +class DraggableNode extends ts.Widget { + constructor(opts) { + super({parent: opts.parent}); + this.ctx = this.acquire(ts.SVGContext); + + new ts.Property(this, 'x', opts.x || 0); + new ts.Property(this, 'y', opts.y || 0); + + this.compile(); + this.bind(); + } + + compile() { + this.elem = this.ctx.svg_elem('g', {}, this.ctx.elem); + this.ctx.svg_elem('rect', { + width: 80, height: 40, rx: 4, fill: '#4a90d9' + }, this.elem); + } + + bind() { + // mousedown on element, mousemove within SVG context + this.set_scope(this.elem, this.ctx.elem); + } + + // Motion handlers (inherited from Widget → Motion) + down(evt) { + this._start_x = this.x; + this._start_y = this.y; + } + + move(evt) { + let dx = evt.pageX - evt.prev_pos.x; + let dy = evt.pageY - evt.prev_pos.y; + this.x = this._start_x + dx; + this.y = this._start_y + dy; + } + + on_x(val) { + this._update_position(); + } + + on_y(val) { + this._update_position(); + } + + _update_position() { + this.ctx.svg_attrs(this.elem, { + transform: `translate(${this.x} ${this.y})` + }); + } +} +``` + +## Pattern: acquire() for Context Lookup + +`acquire(ClassName)` traverses the widget's ancestors and returns the first +instance of the given class. This is the standard way to access shared context. + +```javascript +class ToolbarButton extends ts.Widget { + constructor(opts) { + super({parent: opts.parent}); + // Find the nearest SVGContext ancestor + this.ctx = this.acquire(ts.SVGContext); + // Find a custom application-level ancestor + this.app = this.acquire(Application); + } +} +``` + +## Pattern: Adding/Removing Children + +```javascript +let parent = new ts.Widget({parent: null}); +let child = new ts.Widget({parent: null}); + +parent.add_widget(child); +// child.parent === parent +// parent.children includes child + +parent.remove_widget(child); +// child.parent === null +``` + +## Pitfalls + +1. **Always pass `{parent: ...}` to the Widget constructor.** The parent can be + `null` for root widgets. + +2. **Call `super(opts)` before accessing `this`.** The parent property is set up + in the Widget constructor. + +3. **`acquire()` returns `null` if no ancestor matches.** Always check the + result if the context might not exist. + +4. **`set_scope()` cannot be called during a motion sequence.** It throws if + called while mousedown is active. + +5. **HTMLWidget expects a jQuery-wrapped element** in `opts.elem`. + +6. **SVGContext creates its own `` element** from `opts.name`. The parent + widget must have an `elem` property (jQuery-wrapped) for the SVG to attach to. + +7. **Widget extends Motion**, so every widget can track mouse events via + `set_scope()`, `down()`, `move()`, `up()` — even if you don't need drag. diff --git a/.ai/drag-and-drop.md b/.ai/drag-and-drop.md new file mode 100644 index 0000000..996e329 --- /dev/null +++ b/.ai/drag-and-drop.md @@ -0,0 +1,277 @@ +# Drag and Drop (Native HTML5 DnD) + +This guide explains how to implement drag-and-drop interactions using +treibstoff's `DnD` class. + +## Context + +The `DnD` class wraps the native HTML5 Drag and Drop API into treibstoff's +event system. It is the DnD counterpart to `Motion` (which handles mouse +tracking). Where `Motion` uses `down/move/up` phases, `DnD` uses +`dragstart/dragover/dragleave/drop/dragend` phases. + +Each draggable widget creates its own `DnD` instance. Cross-instance +coordination (knowing *what* is being dragged when a drop occurs) is handled +via the class-level `DnD._drag_source` reference. + +## Key API + +| Member | Description | +|--------|-------------| +| `set_scope(drag, drop)` | Bind drag events on `drag` element, drop events on `drop` element | +| `reset_scope()` | Unbind all event handlers and clear scopes | +| `DnD._drag_source` | Class-level reference to the DnD instance currently being dragged | +| `trigger('dragstart', evt)` | Fired when drag begins | +| `trigger('dragover', evt)` | Fired when dragged over drop target (`evt.source` = drag source) | +| `trigger('dragleave', evt)` | Fired when drag leaves drop target | +| `trigger('drop', evt)` | Fired on drop (`evt.source` = drag source) | +| `trigger('dragend', evt)` | Fired when drag ends (cleanup) | + +## DnD Lifecycle + +``` +User starts dragging element + → _dragstart: sets DnD._drag_source = this, calls dataTransfer.setData + → triggers 'dragstart' event + +Dragged over drop target (repeated) + → _dragover: calls preventDefault() (required to allow drop) + → sets evt.source = DnD._drag_source + → triggers 'dragover' event + +Drag leaves drop target + → triggers 'dragleave' event + +Drop on target + → _drop: calls preventDefault() + → sets evt.source = DnD._drag_source + → triggers 'drop' event + +Drag ends (on the dragged element) + → _dragend: clears DnD._drag_source = null + → triggers 'dragend' event +``` + +**`evt.source`** is set on `dragover` and `drop` events. It references the +`DnD` instance that initiated the drag, enabling cross-instance communication. + +## Comparison with Motion + +| Motion | DnD | Purpose | +|--------|-----|---------| +| `set_scope(down, move)` | `set_scope(drag, drop)` | Bind scopes | +| `reset_state()` | `reset_scope()` | Cleanup | +| `trigger('down', evt)` | `trigger('dragstart', evt)` | Interaction begins | +| `trigger('move', evt)` | `trigger('dragover', evt)` | Interaction continues | +| `trigger('up', evt)` | `trigger('drop', evt)` | Interaction ends | +| `evt.motion` (was it a drag?) | `evt.source` (who is being dragged?) | Event annotation | +| Mouse-based, custom tracking | Native HTML5 DnD API | Underlying mechanism | + +## Pattern 1: Single Element (Drag and Drop on Same Element) + +```javascript +import ts from 'treibstoff'; + +let elem = $('#my-item'); +let dnd = new ts.DnD(); +dnd.set_scope(elem, elem); + +dnd.on('dragstart', function(inst, evt) { + evt.originalEvent.dataTransfer.effectAllowed = 'move'; + elem.addClass('dragging'); +}); + +dnd.on('dragover', function(inst, evt) { + elem.addClass('drag-over'); +}); + +dnd.on('dragleave', function(inst, evt) { + elem.removeClass('drag-over'); +}); + +dnd.on('drop', function(inst, evt) { + elem.removeClass('drag-over'); + console.log('Dropped! Source:', evt.source); +}); + +dnd.on('dragend', function(inst, evt) { + elem.removeClass('dragging'); +}); +``` + +## Pattern 2: Separate Drag Handle and Drop Zone + +Use different elements for drag and drop scopes, e.g. a header as drag +handle and the entire card as drop zone. + +```javascript +let header = group.find('.card-header'); +let card = group.find('.card'); + +let dnd = new ts.DnD(); +dnd.set_scope(header, card); + +dnd.on('dragstart', function(inst, evt) { + evt.originalEvent.dataTransfer.effectAllowed = 'move'; + card.addClass('dragging'); +}); + +dnd.on('drop', function(inst, evt) { + card.removeClass('drag-over'); + // evt.source is the DnD instance of whatever was dragged + handle_drop(evt.source); +}); + +dnd.on('dragend', function(inst, evt) { + card.removeClass('dragging'); +}); +``` + +## Pattern 3: Cross-Instance Drag and Drop + +Each widget has its own DnD instance. When item A is dragged onto item B, +B's drop handler receives A's DnD instance via `evt.source`. + +```javascript +class SortableItem extends ts.Events { + constructor(elem) { + super(); + this.elem = elem; + this.dnd = new ts.DnD(); + this.dnd.set_scope(elem, elem); + + this.dnd.on('dragstart', (inst, evt) => { + evt.originalEvent.dataTransfer.effectAllowed = 'move'; + this.elem.addClass('dragging'); + }); + + this.dnd.on('dragover', (inst, evt) => { + this.elem.addClass('drag-over'); + }); + + this.dnd.on('dragleave', (inst, evt) => { + this.elem.removeClass('drag-over'); + }); + + this.dnd.on('drop', (inst, evt) => { + this.elem.removeClass('drag-over'); + // evt.source is the DnD instance of the dragged item + // Use a map to resolve DnD instance → widget data + this.trigger('on_drop_received', evt.source); + }); + + this.dnd.on('dragend', (inst, evt) => { + this.elem.removeClass('dragging'); + }); + } + + destroy() { + this.dnd.reset_scope(); + } +} +``` + +The parent/orchestrator maintains a `Map` to resolve which widget +a DnD instance belongs to: + +```javascript +class SortableList extends ts.Events { + constructor() { + super(); + this._dnd_map = new Map(); + this.items = []; + } + + add_item(data, container) { + let item = new SortableItem(/* ... */); + this._dnd_map.set(item.dnd, {id: data.id, type: 'item'}); + + item.on('on_drop_received', (inst, source_dnd) => { + let drag_data = this._dnd_map.get(source_dnd); + if (drag_data) { + this.handle_reorder(drag_data, data); + } + }); + + this.items.push(item); + } +} +``` + +## Pattern 4: Default Handler Methods + +Like Motion's `down()`/`move()`/`up()` default handlers, DnD supports +instance methods as default handlers: + +```javascript +let dnd = new ts.DnD(); +dnd.dragstart = function(evt) { + console.log('drag started'); +}; +dnd.drop = function(evt) { + console.log('dropped, source:', evt.source); +}; +dnd.set_scope(drag_elem, drop_elem); +``` + +## Scope Variants + +| Drag Scope | Drop Scope | Use Case | +|------------|------------|----------| +| `elem` | `elem` | Sortable item (drag and drop on same element) | +| `header` | `card` | Group card with drag handle | +| `elem` | `null` | Drag-only (source element, no drop target) | +| `null` | `elem` | Drop-only (target element, not draggable itself) | + +## Event Flow: Item A Dropped on Item B + +``` +User drags Item A onto Item B: + → Item A: DnD._dragstart() + → DnD._drag_source = item_a.dnd + → item_a.dnd triggers 'dragstart' + → Item A adds .dragging class + + → Item B: DnD._dragover() + → evt.source = item_a.dnd (from DnD._drag_source) + → item_b.dnd triggers 'dragover' + → Item B adds .drag-over class + + → Item B: DnD._drop() + → evt.source = item_a.dnd + → item_b.dnd triggers 'drop' + → Item B handler: trigger('on_drop_received', evt.source) + → Parent resolves source via _dnd_map, executes reorder + + → Item A: DnD._dragend() + → DnD._drag_source = null + → item_a.dnd triggers 'dragend' + → Item A removes .dragging class +``` + +## Pitfalls + +1. **`set_scope()` resets the previous scope** automatically. Calling it again + re-binds to the new elements (same behavior as `Motion.set_scope()`). + +2. **`reset_scope()` is safe to call** without a prior `set_scope()`. No error + is thrown. + +3. **Firefox requires `dataTransfer.setData()`** in the `dragstart` handler. + The DnD class handles this automatically with an empty string. + +4. **`preventDefault()` is required** in `dragover` to allow drops. The DnD + class calls it automatically on `evt.originalEvent`. + +5. **`evt.source` is only set on `dragover` and `drop` events.** It is not + available on `dragstart`, `dragleave`, or `dragend`. + +6. **`DnD._drag_source` is cleared on `dragend`.** If you need the source + reference after dragend, capture it during the `drop` event. + +7. **`set_scope(drag, null)` sets up drag-only** (no drop target). Useful for + source-only elements. Similarly, `set_scope(null, drop)` sets up a + drop-only target. + +8. **`reset_scope()` removes the `draggable` attribute** from the drag element. + This is different from Motion where no DOM attributes are involved. diff --git a/.ai/event-handling.md b/.ai/event-handling.md new file mode 100644 index 0000000..42368b8 --- /dev/null +++ b/.ai/event-handling.md @@ -0,0 +1,353 @@ +# Event Handling + +This guide explains how to use treibstoff's event system, DOM listeners, +keyboard state tracking, and event suppression. + +## Context + +Nearly every class in treibstoff extends `Events`, which provides a pub/sub +event dispatcher. On top of this, `create_listener` creates classes that bridge +DOM events to treibstoff events, and `KeyState` tracks keyboard modifier keys. + +## Key API + +| Class/Function | Purpose | +|----------------|---------| +| `ts.Events` | Base pub/sub dispatcher — `on()`, `off()`, `trigger()` | +| `ts.create_listener(event, base)` | Factory for DOM event listener classes | +| `ts.ClickListener` | Pre-built click listener class | +| `ts.clickListener` | Pre-built click listener mixin | +| `ts.ChangeListener` | Pre-built change listener class | +| `ts.changeListener` | Pre-built change listener mixin | +| `ts.KeyState` | Keyboard modifier state tracker | + +## Pattern 1: Basic Events + +```javascript +import ts from 'treibstoff'; + +class Model extends ts.Events { + constructor() { + super(); + } + + save() { + // do save work... + this.trigger('on_save', {id: 42}); + } +} + +let model = new Model(); + +// Subscribe +let handler = function(inst, data) { + console.log('Saved:', data.id); // inst = model, data = {id: 42} +}; +model.on('on_save', handler); + +// Trigger +model.save(); // logs: "Saved: 42" + +// Unsubscribe specific handler +model.off('on_save', handler); + +// Unsubscribe all handlers for event +model.off('on_save'); +``` + +**Key points:** +- `on()` returns `this` for chaining: `obj.on('a', fn1).on('b', fn2)` +- `off()` returns `this` for chaining +- Duplicate subscribers are silently ignored (same function registered twice) +- `trigger(event, ...args)` passes all extra arguments to subscribers +- Subscribers receive `(instance, ...args)` — the emitting instance is always first + +## Pattern 2: Default Event Handlers + +If a method with the event name exists on the instance, it's called first. + +```javascript +class Widget extends ts.Events { + constructor() { + super(); + } + + // Called when trigger('on_update', val) fires + on_update(val) { + console.log('Default handler:', val); + } +} + +let w = new Widget(); +w.on('on_update', function(inst, val) { + console.log('External handler:', val); +}); +w.trigger('on_update', 'data'); +// logs: "Default handler: data" +// logs: "External handler: data" +``` + +## Pattern 3: Bind from Options + +Shortcut for subscribing to events from a constructor options object. + +```javascript +class Overlay extends ts.Events { + constructor(opts) { + super(); + this.bind_from_options(['on_open', 'on_close'], opts); + } +} + +let ol = new Overlay({ + on_open: function(inst) { console.log('opened'); }, + on_close: function(inst) { console.log('closed'); } +}); +ol.trigger('on_open'); // logs: "opened" +``` + +## Pattern 4: Event Suppression + +Batch operations without triggering events, then fire a single summary event. + +```javascript +class DataStore extends ts.Events { + constructor() { + super(); + } + + reload(data) { + this.suppress_events(() => { + this.delete_all(); // no events fired + this._data = data; + this.create_all(); // no events fired + }); + this.trigger('data_changed', data); // single event after batch + } +} +``` + +During `suppress_events(fn)`, all calls to `trigger()` are no-ops. + +## Pattern 5: Click Listener (Base Class) + +```javascript +class ToggleButton extends ts.ClickListener { + constructor(elem) { + // elem must be a jQuery-wrapped DOM element + super({elem: elem}); + this.active = false; + } + + on_click(evt) { + this.active = !this.active; + this.elem.toggleClass('active', this.active); + } + + destroy() { + // Unbinds the DOM event listener + super.destroy(); + } +} + +let btn = new ToggleButton($('#my-button')); +``` + +**How it works:** +1. Constructor binds `click` event on `elem` +2. On click, triggers `on_click` as a treibstoff event +3. `on_click(evt)` method is called as the default handler +4. External subscribers can also listen: `btn.on('on_click', fn)` +5. `destroy()` unbinds the DOM event + +## Pattern 6: Listener as Mixin + +Use when your class already has a base class that extends `Events`. + +```javascript +// Create a click listener mixin +let clickListener = Base => ts.create_listener('click', Base); + +class InteractiveInput extends clickListener(ts.FormInput) { + // FormInput extends Events, so this works + on_click(evt) { + this.elem.select(); + } +} +``` + +The pre-built mixins are: +- `ts.clickListener(Base)` — click events +- `ts.changeListener(Base)` — change events + +## Pattern 7: Custom Listener + +```javascript +// Create a custom listener for any DOM event +let DblClickListener = ts.create_listener('dblclick'); + +class EditableLabel extends DblClickListener { + constructor(elem) { + super({elem: elem}); + } + + on_dblclick(evt) { + this.elem.attr('contenteditable', 'true'); + this.elem.focus(); + } +} + +// As a mixin: +let dblclickListener = Base => ts.create_listener('dblclick', Base); +``` + +## Pattern 8: KeyState (Keyboard Modifiers) + +Track modifier keys (Ctrl, Shift, Alt, Enter, Escape, Delete) globally. + +```javascript +class Editor extends ts.Events { + constructor() { + super(); + // Optional filter: return true to suppress the event + this.key_state = new ts.KeyState(function(evt) { + // Don't track keys when a text input is focused + return evt.target.tagName === 'INPUT'; + }); + + this.key_state.on('keydown', this.on_keydown.bind(this)); + this.key_state.on('keyup', this.on_keyup.bind(this)); + } + + on_keydown(key_state, evt) { + if (key_state.ctrl && evt.keyCode === 65) { + // Ctrl+A: select all + evt.preventDefault(); + this.select_all(); + } + if (key_state.delete) { + this.delete_selected(); + } + if (key_state.esc) { + this.deselect_all(); + } + } + + on_keyup(key_state, evt) { + // modifier released + } + + destroy() { + this.key_state.unload(); // remove window keydown/keyup listeners + } +} +``` + +**Available modifier properties:** +| Property | Key Code | Key | +|----------|----------|-----| +| `ctrl` | 17 | Control | +| `shift` | 16 | Shift | +| `alt` | 18 | Alt | +| `enter` | 13 | Enter | +| `esc` | 27 | Escape | +| `delete` | 46 | Delete | + +These are boolean flags — `true` while the key is held down, `false` when released. + +## Pattern 9: Lifecycle Integration with Ajax + +Listeners automatically call `ts.ajax.attach(this, elem)` in their constructor. +When the DOM element is replaced by an Ajax operation, `destroy()` is called +automatically. + +```javascript +class AutoCleanWidget extends ts.ClickListener { + constructor(elem) { + super({elem: elem}); + // ts.ajax.attach(this, elem) is called internally + this.tooltip = new ExternalTooltip(elem[0]); + } + + on_click(evt) { + this.tooltip.toggle(); + } + + destroy() { + this.tooltip.dispose(); + super.destroy(); + } +} + +// Register via ajax.register for automatic lifecycle management +$(function() { + ts.ajax.register(function(context) { + $('.my-widget', context).each(function() { + new AutoCleanWidget($(this)); + }); + }, true); +}); +``` + +## Complete Example + +```javascript +import ts from 'treibstoff'; + +class InteractivePanel extends ts.ClickListener { + constructor(elem) { + super({elem: elem}); + this.key_state = new ts.KeyState(); + this.key_state.on('keydown', this.on_keydown.bind(this)); + } + + on_click(evt) { + if (this.key_state.ctrl) { + // Ctrl+Click: add to selection + this.trigger('on_multi_select'); + } else { + // Normal click: single select + this.trigger('on_select'); + } + } + + on_keydown(ks, evt) { + if (ks.esc) { + this.trigger('on_deselect'); + } + } + + destroy() { + this.key_state.unload(); + super.destroy(); + } +} + +let panel = new InteractivePanel($('#panel')); +panel.on('on_select', (inst) => console.log('Selected')); +panel.on('on_multi_select', (inst) => console.log('Multi-selected')); +panel.on('on_deselect', (inst) => console.log('Deselected')); +``` + +## Pitfalls + +1. **`create_listener` requires the base class to extend `Events`** (or be + `Events` itself). It throws if the base doesn't inherit from `Events`. + +2. **Listeners require `this.elem`** (a jQuery-wrapped element). It must be + set before the listener constructor runs — either via `opts.elem` or by the + superclass. + +3. **Always call `destroy()`** (or `super.destroy()` in subclasses) to unbind + DOM events. Failing to do so causes memory leaks. + +4. **KeyState binds to `window`.** Only one KeyState should be active at a time + (per logical keyboard context). Call `unload()` when done. + +5. **`suppress_events()` is synchronous.** If the callback throws, events + remain suppressed. The implementation resets the flag after the function + returns. + +6. **Event subscriber functions receive `(instance, ...args)`** — the emitting + object is always the first argument. Default method handlers on the instance + receive only `(...args)` (no instance prefix, since `this` is available). diff --git a/.ai/http-requests.md b/.ai/http-requests.md new file mode 100644 index 0000000..21d33c2 --- /dev/null +++ b/.ai/http-requests.md @@ -0,0 +1,259 @@ +# HTTP Requests + +This guide explains how to make HTTP requests using treibstoff's `HTTPRequest` +class and `http_request` convenience function. + +## Context + +Treibstoff wraps jQuery's `$.ajax` with automatic spinner management, error +display, 403 redirect handling, and URL query parameter parsing. The +`http_request` function is the primary API for application code. + +## Key API + +| Class/Function | Purpose | +|----------------|---------| +| `ts.http_request(opts)` | Execute HTTP request (convenience function) | +| `ts.HTTPRequest` | Request class with spinner and error handling | +| `ts.spinner` | Loading spinner singleton | + +## Pattern 1: Basic GET Request + +```javascript +ts.http_request({ + url: '/api/items', + type: 'json', + success: function(data, status, request) { + console.log('Items:', data); + } +}); +``` + +**Defaults:** +- `type`: `'html'` +- `method`: `'GET'` +- `cache`: `false` +- Spinner is shown during request +- On error: `ts.show_error()` with status code + +## Pattern 2: POST Request + +```javascript +ts.http_request({ + url: '/api/items/42', + method: 'POST', + type: 'json', + params: { + title: 'Updated Title', + status: 'active' + }, + success: function(data, status, request) { + console.log('Updated:', data); + } +}); +``` + +## Pattern 3: URL with Query Parameters + +Query parameters in the URL are automatically parsed and merged with `params`. +If the same key exists in both, `params` takes precedence. + +```javascript +ts.http_request({ + url: '/api/items?page=1&size=10', + type: 'json', + params: { + size: 25 // overrides size=10 from URL + }, + success: function(data) { + // request sent to /api/items with params: {page: '1', size: 25} + } +}); +``` + +## Pattern 4: Custom Error Handling + +```javascript +ts.http_request({ + url: '/api/items/42', + type: 'json', + success: function(data) { + console.log('OK:', data); + }, + error: function(request, status, error) { + if (status === 404) { + console.log('Item not found'); + } else { + ts.show_error(`Request failed: ${status} ${error}`); + } + } +}); +``` + +**Default error behavior:** +- HTTP 403 → Redirect to `/login` +- Other errors → `ts.show_error()` with status and message +- Request abort (status 0) → Silently ignored + +## Pattern 5: Without Spinner + +```javascript +ts.http_request({ + url: '/api/heartbeat', + type: 'json', + spinner: null, // disable spinner + success: function(data) { + // background request without UI feedback + } +}); +``` + +## Pattern 6: Custom 403 Redirect + +```javascript +ts.http_request({ + url: '/api/admin/settings', + type: 'json', + default_403: '/unauthorized', // custom redirect path + success: function(data) { + console.log('Settings:', data); + } +}); +``` + +## Pattern 7: Using HTTPRequest Class Directly + +For multiple requests sharing the same configuration: + +```javascript +let request = new ts.HTTPRequest({ + spinner: ts.spinner, + default_403: '/login' +}); + +request.execute({ + url: '/api/items', + type: 'json', + success: function(data) { + console.log(data); + } +}); + +request.execute({ + url: '/api/categories', + type: 'json', + success: function(data) { + console.log(data); + } +}); +``` + +## Pattern 8: Spinner Management + +The spinner tracks a display count — multiple concurrent requests are handled +properly. + +```javascript +// Manual spinner control +ts.spinner.show(); // count: 1 → spinner appears +ts.spinner.show(); // count: 2 → still showing +ts.spinner.hide(); // count: 1 → still showing +ts.spinner.hide(); // count: 0 → spinner disappears + +// Force hide (resets count) +ts.spinner.hide(true); // count: 0, spinner removed immediately +``` + +## Complete Example + +```javascript +import ts from 'treibstoff'; + +class ItemService { + constructor(base_url) { + this.base_url = base_url; + } + + list(params, callback) { + ts.http_request({ + url: this.base_url, + type: 'json', + params: params, + success: callback + }); + } + + get(id, callback) { + ts.http_request({ + url: `${this.base_url}/${id}`, + type: 'json', + success: callback, + error: function(req, status, error) { + if (status === 404) { + ts.show_warning('Item not found.'); + } else { + ts.show_error(`Failed to load item: ${error}`); + } + } + }); + } + + save(id, data, callback) { + ts.http_request({ + url: `${this.base_url}/${id}`, + method: 'POST', + type: 'json', + params: data, + success: function(response) { + ts.show_info('Item saved.'); + callback(response); + } + }); + } + + delete(id, callback) { + ts.show_dialog({ + title: 'Delete Item', + message: 'Are you sure?', + on_confirm: function() { + ts.http_request({ + url: `${this.base_url}/${id}`, + method: 'POST', + type: 'json', + params: {action: 'delete'}, + success: function(response) { + ts.show_info('Item deleted.'); + callback(response); + } + }); + }.bind(this) + }); + } +} + +let items = new ItemService('/api/items'); +items.list({page: 1}, function(data) { + console.log('Items:', data); +}); +``` + +## Pitfalls + +1. **Query parameters in the URL are parsed automatically.** Don't manually + parse them — pass the full URL with query string and use `params` for + additional/overriding parameters. + +2. **The spinner is shown/hidden automatically** for every request. If you + don't want it, pass `spinner: null`. + +3. **Error handler receives `(request, status, error)`** where `status` is + the numeric HTTP status code (not the jQuery status string). + +4. **Requests with status 0 (aborted) are silently ignored** — the error + callback is not called and the spinner is force-hidden. + +5. **`http_request` creates a new `HTTPRequest` instance per call.** For + shared configuration, instantiate `HTTPRequest` directly. + +6. **Caching is disabled by default** (`cache: false`). Set `cache: true` + for cacheable GET requests. diff --git a/.ai/motion-tracking.md b/.ai/motion-tracking.md new file mode 100644 index 0000000..35b1ee3 --- /dev/null +++ b/.ai/motion-tracking.md @@ -0,0 +1,307 @@ +# Motion Tracking (Drag, Resize, Select) + +This guide explains how to implement drag, resize, and selection interactions +using treibstoff's `Motion` class. + +## Context + +The `Motion` class tracks mouse interactions in three phases: `down` (mousedown), +`move` (mousemove), and `up` (mouseup). It separates the "down scope" (where +mousedown is detected) from the "move scope" (where mousemove is tracked), +enabling flexible interaction patterns like grab handles, resize corners, and +rubber-band selection. + +`Widget` extends `Motion`, so every widget can use motion tracking without +additional setup. + +## Key API + +| Member | Description | +|--------|-------------| +| `set_scope(down, move)` | Set DOM scopes for motion tracking | +| `reset_state()` | Reset internal motion state | +| `trigger('down', evt)` | Fired on mousedown | +| `trigger('move', evt)` | Fired on mousemove (evt has `prev_pos` and `motion`) | +| `trigger('up', evt)` | Fired on mouseup (evt has `motion` flag) | + +## Motion Lifecycle + +``` +mousedown on down_scope + → _mousedown: stores initial position, binds mousemove on move_scope + → triggers 'down' event + +mousemove on move_scope (repeated) + → _mousemove: sets evt.prev_pos and evt.motion + → triggers 'move' event + → updates prev_pos + +mouseup on document + → _mouseup: unbinds mousemove, sets evt.motion flag + → triggers 'up' event + → reset_state() +``` + +**`evt.motion`** is `false` if the mouse didn't actually move (just a click), +`true` if it did. Use this to distinguish clicks from drags. + +**`evt.prev_pos`** contains `{x, y}` from the previous move event (or the +mousedown position for the first move). Use it to calculate deltas. + +## Pattern 1: Simple Drag + +```javascript +import ts from 'treibstoff'; + +class DraggableBox extends ts.Motion { + constructor(elem) { + super(); + this._elem = elem; + this._offset = {x: 0, y: 0}; + // mousedown and mousemove on the same element + this.set_scope(elem, elem); + } + + down(evt) { + this._offset = { + x: evt.pageX - parseInt(this._elem.style.left || 0), + y: evt.pageY - parseInt(this._elem.style.top || 0) + }; + } + + move(evt) { + this._elem.style.left = (evt.pageX - this._offset.x) + 'px'; + this._elem.style.top = (evt.pageY - this._offset.y) + 'px'; + } + + up(evt) { + if (evt.motion) { + console.log('Dragged to:', this._elem.style.left, this._elem.style.top); + } else { + console.log('Clicked (no drag)'); + } + } +} +``` + +## Pattern 2: Resize Handle + +Use a narrow down scope (the grab handle) and a wider move scope (the document) +so the mouse can move freely during resize. + +```javascript +class Resizer extends ts.Motion { + constructor(handle, target) { + super(); + this._handle = handle; + this._target = target; + this._initial_w = 0; + this._initial_h = 0; + // mousedown on the handle, mousemove on the entire document + this.set_scope(handle, document); + } + + down(evt) { + this._initial_w = this._target.offsetWidth; + this._initial_h = this._target.offsetHeight; + this._start_x = evt.pageX; + this._start_y = evt.pageY; + } + + move(evt) { + let dx = evt.pageX - this._start_x; + let dy = evt.pageY - this._start_y; + this._target.style.width = Math.max(50, this._initial_w + dx) + 'px'; + this._target.style.height = Math.max(50, this._initial_h + dy) + 'px'; + } + + up(evt) { + if (evt.motion) { + console.log('Resized to:', this._target.style.width, this._target.style.height); + } + } +} +``` + +## Pattern 3: Rubber-Band Selection + +Down on the background element, move within a container scope. + +```javascript +class BoxSelect extends ts.Motion { + constructor(container) { + super(); + this._container = container; + this._rect = null; + // mousedown on container, mousemove within container + this.set_scope(container, container); + } + + down(evt) { + this._start = {x: evt.pageX, y: evt.pageY}; + this._rect = document.createElement('div'); + this._rect.className = 'selection-rect'; + this._container.appendChild(this._rect); + } + + move(evt) { + let x = Math.min(this._start.x, evt.pageX); + let y = Math.min(this._start.y, evt.pageY); + let w = Math.abs(evt.pageX - this._start.x); + let h = Math.abs(evt.pageY - this._start.y); + Object.assign(this._rect.style, { + left: x + 'px', top: y + 'px', + width: w + 'px', height: h + 'px' + }); + } + + up(evt) { + if (this._rect) { + this._container.removeChild(this._rect); + this._rect = null; + } + if (evt.motion) { + // Calculate selection area and find items within + console.log('Selection from', this._start, 'to', {x: evt.pageX, y: evt.pageY}); + } + } +} +``` + +## Pattern 4: SVG Widget Drag + +When used inside a Widget hierarchy with SVGContext: + +```javascript +class DraggableNode extends ts.Widget { + constructor(opts) { + super({parent: opts.parent}); + this.ctx = this.acquire(ts.SVGContext); + + new ts.Property(this, 'x', opts.x || 0); + new ts.Property(this, 'y', opts.y || 0); + + this.elem = this.ctx.svg_elem('g', {}, this.ctx.elem); + this.ctx.svg_elem('rect', { + width: 80, height: 40, fill: '#4a90d9' + }, this.elem); + + // mousedown on this element, mousemove within SVG viewport + this.set_scope(this.elem, this.ctx.elem); + } + + down(evt) { + this._drag_start = {x: evt.pageX, y: evt.pageY}; + this._pos_start = {x: this.x, y: this.y}; + } + + move(evt) { + let dx = evt.pageX - this._drag_start.x; + let dy = evt.pageY - this._drag_start.y; + this.x = this._pos_start.x + dx; + this.y = this._pos_start.y + dy; + } + + on_x(val) { this._update_transform(); } + on_y(val) { this._update_transform(); } + + _update_transform() { + this.ctx.svg_attrs(this.elem, { + transform: `translate(${this.x} ${this.y})` + }); + } +} +``` + +## Pattern 5: Using Motion Events Instead of Methods + +Instead of overriding `down/move/up` methods, you can subscribe to events: + +```javascript +let motion = new ts.Motion(); +motion.set_scope(downElem, moveElem); + +motion.on('down', function(inst, evt) { + console.log('mousedown at', evt.pageX, evt.pageY); +}); + +motion.on('move', function(inst, evt) { + let dx = evt.pageX - evt.prev_pos.x; + let dy = evt.pageY - evt.prev_pos.y; + console.log('delta:', dx, dy); +}); + +motion.on('up', function(inst, evt) { + console.log('mouseup, was drag:', evt.motion); +}); +``` + +## Scope Variants + +| Down Scope | Move Scope | Use Case | +|------------|------------|----------| +| `elem` | `elem` | Simple drag within the element | +| `handle` | `document` | Resize via grab handle (wide movement range) | +| `elem` | `container` | Drag within a bounded area (e.g. SVG viewport) | +| `background` | `background` | Rubber-band selection on canvas | + +## Complete Example: Pan and Zoom + +```javascript +class Pane extends ts.Widget { + constructor(opts) { + super({parent: opts.parent}); + this.ctx = this.acquire(ts.SVGContext); + this.elem = this.ctx.svg_elem('g', {}, this.ctx.elem); + + // Pan: drag on SVG background + this.set_scope(this.ctx.elem, this.ctx.elem); + } + + down(evt) { + this._pan_start = { + x: this.ctx.xyz.x, + y: this.ctx.xyz.y + }; + } + + move(evt) { + let dx = evt.pageX - evt.prev_pos.x; + let dy = evt.pageY - evt.prev_pos.y; + this.ctx.xyz.x = this._pan_start.x + dx; + this.ctx.xyz.y = this._pan_start.y + dy; + this._apply_transform(); + } + + _apply_transform() { + let xyz = this.ctx.xyz; + this.ctx.svg_attrs(this.elem, { + transform: `translate(${xyz.x} ${xyz.y}) scale(${xyz.z})` + }); + } +} +``` + +## Pitfalls + +1. **`set_scope()` cannot be called during a motion sequence.** It throws + `'Attempt to set motion scope while handling'` if mousedown is active. + +2. **`set_scope()` unbinds the previous scope** automatically. Calling it + again re-binds to the new elements. + +3. **`mouseup` is always bound to `document`**, not to the move scope. This + ensures the up event is captured even if the mouse leaves the move scope. + +4. **`evt.stopPropagation()` is called** in all three handlers. This prevents + parent elements from receiving the same mouse events. + +5. **If `move` scope is `null`**, only `down` and `up` are tracked (no + `move` events). Useful for click-only detection with motion awareness. + +6. **`evt.prev_pos`** is only available in `move` events. In `down`, use + `evt.pageX/pageY` directly. In `up`, `evt.motion` tells you if movement + occurred. + +7. **Widget extends Motion**, so `set_scope()`, `down()`, `move()`, `up()` + are available on all widgets without explicit inheritance. diff --git a/.ai/overlays-dialogs.md b/.ai/overlays-dialogs.md new file mode 100644 index 0000000..9dcbb8d --- /dev/null +++ b/.ai/overlays-dialogs.md @@ -0,0 +1,266 @@ +# Overlays, Dialogs, and Messages + +This guide explains how to create modal overlays, confirmation dialogs, and +message popups using treibstoff. + +## Context + +Treibstoff provides a Bootstrap-compatible modal overlay system with stacking +support (multiple overlays at once), lifecycle events, and Ajax integration. + +## Key API + +| Class/Function | Purpose | +|----------------|---------| +| `ts.Overlay` | Base modal overlay with header, body, footer | +| `ts.Message` | Overlay with message content and close button | +| `ts.Dialog` | Confirmation dialog with OK/Cancel buttons | +| `ts.show_message(opts)` | Show a message overlay | +| `ts.show_info(message)` | Show an info message | +| `ts.show_warning(message)` | Show a warning message | +| `ts.show_error(message)` | Show an error message | +| `ts.show_dialog(opts)` | Show a confirmation dialog | +| `ts.get_overlay(uid)` | Get an open overlay by its UID | +| `ts.ajax.overlay(opts)` | Load server content into an overlay | + +## Pattern 1: Quick Messages + +```javascript +// Info message +ts.show_info('Operation completed successfully.'); + +// Warning +ts.show_warning('This action cannot be undone.'); + +// Error +ts.show_error('
Error: Connection timeout
'); + +// Custom message with title and flavor +ts.show_message({ + title: 'Import Results', + message: '

42 records imported.

3 records skipped.

', + flavor: 'info', + css: 'modal-lg' // optional: Bootstrap size class +}); +``` + +**Flavors:** `'info'`, `'warning'`, `'error'` — applied as CSS class on the modal. + +## Pattern 2: Confirmation Dialog + +```javascript +ts.show_dialog({ + title: 'Confirm Delete', + message: 'Are you sure you want to delete this item?', + on_confirm: function(inst) { + // inst is the Dialog instance + console.log('User confirmed'); + delete_item(42); + } +}); +``` + +The dialog shows OK and Cancel buttons. OK triggers `on_confirm` and closes. +Cancel just closes. + +## Pattern 3: Custom Overlay + +```javascript +let overlay = new ts.Overlay({ + uid: 'my-overlay', // optional, auto-generated if omitted + title: 'Custom Overlay', + content: '

Loading...

', + flavor: 'info', + css: 'my-custom-class', + container: $('#overlay-container'), // defaults to $('body') + on_open: function(inst) { + console.log('Overlay opened'); + }, + on_close: function(inst) { + console.log('Overlay closed'); + } +}); + +overlay.open(); + +// Later: +overlay.close(); +``` + +**Overlay DOM structure after `open()`:** +```html + +``` + +## Pattern 4: Programmatic Content Update + +```javascript +let overlay = new ts.Overlay({title: 'Loading...'}); +overlay.open(); + +// Update body content +overlay.body.html('

New content loaded

'); + +// Add footer buttons via compile_template +ts.compile_template(overlay, ` + +`, overlay.footer); + +overlay.on_save = function() { + console.log('Save clicked'); + overlay.close(); +}; +``` + +## Pattern 5: Stacked Overlays + +Overlays automatically stack with increasing z-index. + +```javascript +let first = new ts.Overlay({title: 'First'}); +first.open(); + +let second = new ts.Overlay({title: 'Second'}); +second.open(); +// second appears above first (z-index is higher) + +second.close(); +// first is still visible +first.close(); +``` + +## Pattern 6: Ajax Overlay (Server Content) + +Load content from the server into an overlay: + +```javascript +let overlay = ts.ajax.overlay({ + action: 'editform', + target: 'http://example.com/items/42/edit', + title: 'Edit Item', + css: 'overlay-form', + on_close: function(inst) { + // Refresh the item list after edit + ts.ajax.trigger({ + name: 'contextchanged', + selector: '#item-list', + target: '/items' + }); + } +}); + +// Overlay UID for later reference +let uid = overlay.uid; +``` + +**Close by UID:** +```javascript +ts.ajax.overlay({close: true, uid: uid}); +``` + +## Pattern 7: Lookup Open Overlay + +```javascript +let overlay = ts.get_overlay('my-overlay'); +if (overlay) { + overlay.body.html('

Updated content

'); +} else { + console.log('Overlay not found or not open'); +} +``` + +## Pattern 8: Custom Dialog Subclass + +```javascript +class ConfirmWithInput extends ts.Dialog { + constructor(opts) { + super(opts); + this.input = $(''); + this.body.append(this.input); + } + + on_ok_btn_click() { + let value = this.input.val(); + this.close(); + this.trigger('on_confirm', value); + } +} + +let dialog = new ConfirmWithInput({ + title: 'Enter Name', + message: 'Please provide a name:', + on_confirm: function(inst, value) { + console.log('Name entered:', value); + } +}); +dialog.open(); +``` + +## Complete Example + +```javascript +import ts from 'treibstoff'; + +function deleteItemWithConfirm(itemId) { + ts.show_dialog({ + title: 'Delete Item', + message: `Are you sure you want to delete item #${itemId}?`, + on_confirm: function() { + ts.http_request({ + url: `/api/items/${itemId}`, + method: 'POST', + type: 'json', + params: {action: 'delete'}, + success: function(data) { + ts.show_info('Item deleted successfully.'); + ts.ajax.action({ + name: 'itemlist', + selector: '#item-list', + mode: 'inner', + url: '/items', + params: {} + }); + } + }); + } + }); +} +``` + +## Pitfalls + +1. **Overlays use `compile_template` internally.** The overlay's `wrapper`, + `backdrop`, `elem`, `body`, `footer` are all available as properties after + construction. + +2. **`close()` calls `ajax_destroy` on the wrapper** before removing it from + the DOM. This properly cleans up any Ajax-bound widgets inside the overlay. + +3. **`show_message/info/warning/error` auto-focus** the first button on open. + +4. **The `body` class `modal-open`** is added when an overlay opens and removed + when the last visible overlay closes. This prevents body scrolling. + +5. **`get_overlay(uid)` returns `null`** if the element doesn't exist or has + no overlay data. Always null-check the result. + +6. **Dialog's OK button handler** fires `on_confirm` after closing. Subscribe + to `on_confirm` via the constructor options, not via a method override. + +7. **Overlay UIDs** can be specified or auto-generated. When using Ajax overlays, + the UID is sent as `ajax.overlay-uid` parameter to the server. diff --git a/.ai/overview.md b/.ai/overview.md new file mode 100644 index 0000000..3e5febd --- /dev/null +++ b/.ai/overview.md @@ -0,0 +1,235 @@ +# Treibstoff — Architecture Overview + +Treibstoff is a JavaScript utility library for building browser-based applications. +It provides reactive properties, widget hierarchies, event handling, SVG graphics, +server-side rendering (SSR) via Ajax, forms, overlays, HTTP requests, WebSocket +communication, and template parsing. + +All classes are exported through a single namespace object (`ts`): + +```javascript +import ts from 'treibstoff'; +``` + +## Module Map + +``` +src/ +├── treibstoff.js # Main aggregator — re-exports all public API members +├── events.js # Events class — pub/sub event dispatcher (base for most classes) +├── properties.js # 9 property types: Property, BoundProperty, CSSProperty, +│ # AttrProperty, InputProperty, DataProperty, TextProperty, +│ # SVGProperty, ButtonProperty +├── widget.js # Widget (parent/child hierarchy), HTMLWidget, SVGContext, +│ # Button, Collapsible, Visibility +├── listener.js # create_listener() factory, ClickListener, ChangeListener +├── motion.js # Motion class — mousedown/move/up tracking +├── clock.js # ClockFrameEvent, ClockTimeoutEvent, ClockIntervalEvent, Clock +├── keystate.js # KeyState — keyboard modifier tracking (ctrl, shift, alt, etc.) +├── form.js # Form, FormInput, FormField, FormCheckbox, FormSelect, +│ # FormRemoteSelect, lookup_form_elem +├── overlay.js # Overlay, Dialog, Message, show_dialog, show_message, +│ # show_info, show_warning, show_error, get_overlay +├── spinner.js # LoadingSpinner (singleton: spinner) +├── request.js # HTTPRequest (singleton: http_request) +├── parser.js # Parser, TemplateParser, HTMLParser, SVGParser, +│ # compile_template, compile_svg, extract_number +├── utils.js # ~20 utility functions (URL parsing, cookies, SVG, DOM, etc.) +├── websocket.js # Websocket class + state constants +├── bootstrap.js # Bootstrap 5 cleanup integration +└── ssr/ # Server-Side Rendering / Ajax system + ├── ajax.js # Ajax class (main orchestrator) + ├── util.js # AjaxUtil, AjaxOperation base classes + ├── action.js # AjaxAction — server action execution + ├── dispatcher.js # AjaxDispatcher — DOM attribute event handler + ├── event.js # AjaxEvent — event triggering + ├── form.js # AjaxForm — form submission + ├── handle.js # AjaxHandle — DOM manipulation & continuation + ├── overlay.js # AjaxOverlay — overlay operations + ├── parser.js # AjaxParser — parse ajax: attributes from DOM + ├── path.js # AjaxPath — browser history management + └── destroy.js # AjaxDestroy — DOM cleanup on replacement +``` + +## Inheritance Hierarchy + +``` +Events +├── Property +│ └── BoundProperty +│ ├── CSSProperty +│ ├── AttrProperty +│ ├── TextProperty +│ ├── DataProperty +│ ├── InputProperty +│ ├── SVGProperty +│ └── ButtonProperty +├── Motion +│ └── Widget +│ ├── HTMLWidget +│ │ └── SVGContext +│ └── Button (via ClickListener) +├── Visibility +│ └── FormField +├── KeyState +├── Overlay +│ └── Message +│ └── Dialog +├── LoadingSpinner +├── HTTPRequest +├── Websocket +├── Clock +├── ClockFrameEvent / ClockTimeoutEvent / ClockIntervalEvent +├── FormInput +│ ├── FormSelect (via changeListener mixin) +│ │ └── FormRemoteSelect +│ └── FormCheckbox (via changeListener mixin) +├── AjaxUtil +│ ├── AjaxOperation +│ │ ├── AjaxAction +│ │ │ └── AjaxOverlay +│ │ ├── AjaxEvent +│ │ └── AjaxPath +│ ├── AjaxHandle +│ ├── AjaxDispatcher +│ └── Ajax +├── Collapsible (plain class, no Events base) +└── Form (plain class, no Events base) +``` + +## Key Design Patterns + +1. **Everything extends Events** — Nearly all classes inherit from `Events`, + providing `on()`, `off()`, `trigger()`, and `suppress_events()`. + +2. **Property binding** — `new ts.Property(this, 'name', default)` defines a + getter/setter on the instance. When the value changes, `on_name(val)` is + triggered automatically. + +3. **Widget hierarchy** — `super({parent: p})` establishes parent-child + relationships. `this.acquire(ClassName)` traverses ancestors to find a + specific type. + +4. **Lifecycle** — constructor → compile() → bind() → update() → destroy(). + +5. **Singletons** — `ts.spinner`, `ts.http_request`, `ts.ajax`, `ts.clock`. + +6. **SSR/Ajax** — HTML attributes (`ajax:bind`, `ajax:action`, `ajax:event`, + `ajax:overlay`, `ajax:path`) declaratively bind server interactions. + The `AjaxParser` walks the DOM, the `AjaxDispatcher` intercepts events, + and operation classes (`AjaxAction`, `AjaxEvent`, `AjaxOverlay`, `AjaxPath`) + execute the work. + +7. **Template compilation** — `ts.compile_template(widget, html, container)` + parses HTML with `t-elem`, `t-prop`, `t-val`, `t-type` attributes to + auto-wire DOM references and properties onto a widget instance. + +## Public API Members + +### Classes + +| Class | Module | Description | +|-------|--------|-------------| +| `Events` | events.js | Pub/sub event dispatcher | +| `Property` | properties.js | Observable property | +| `BoundProperty` | properties.js | Property bound to DOM context | +| `CSSProperty` | properties.js | Syncs to CSS style | +| `AttrProperty` | properties.js | Syncs to HTML attribute | +| `TextProperty` | properties.js | Syncs to textContent | +| `DataProperty` | properties.js | Syncs to data object | +| `InputProperty` | properties.js | Syncs to input element value | +| `SVGProperty` | properties.js | Syncs to SVG attribute | +| `ButtonProperty` | properties.js | Syncs to button, fires click/down/up | +| `Widget` | widget.js | Parent/child hierarchy | +| `HTMLWidget` | widget.js | Widget wrapping a DOM element | +| `SVGContext` | widget.js | Widget wrapping an SVG element | +| `Button` | widget.js | Selectable button widget | +| `Visibility` | widget.js | Show/hide element | +| `Collapsible` | widget.js | Collapse/expand element | +| `Motion` | motion.js | Mouse motion tracking | +| `KeyState` | keystate.js | Keyboard modifier tracking | +| `Clock` | clock.js | Clock event factory | +| `ClockFrameEvent` | clock.js | requestAnimationFrame wrapper | +| `ClockTimeoutEvent` | clock.js | setTimeout wrapper | +| `ClockIntervalEvent` | clock.js | setInterval wrapper | +| `Overlay` | overlay.js | Modal overlay | +| `Message` | overlay.js | Message overlay | +| `Dialog` | overlay.js | Confirmation dialog | +| `Form` | form.js | Form container | +| `FormInput` | form.js | Form input wrapper | +| `FormField` | form.js | Form field with visibility | +| `FormCheckbox` | form.js | Checkbox input | +| `FormSelect` | form.js | Select input | +| `FormRemoteSelect` | form.js | Select with remote data fetch | +| `LoadingSpinner` | spinner.js | Loading animation | +| `HTTPRequest` | request.js | HTTP request handler | +| `Websocket` | websocket.js | WebSocket wrapper | +| `Parser` | parser.js | Base DOM walker | +| `TemplateParser` | parser.js | Template attribute parser | +| `HTMLParser` | parser.js | HTML template parser | +| `SVGParser` | parser.js | SVG template parser | +| `Ajax` | ssr/ajax.js | SSR orchestrator singleton | + +### Functions + +| Function | Module | Description | +|----------|--------|-------------| +| `create_listener(event, base)` | listener.js | Create listener class/mixin | +| `compile_template(inst, tmpl, container)` | parser.js | Compile HTML template | +| `compile_svg(inst, tmpl, container)` | parser.js | Compile SVG template | +| `extract_number(val)` | parser.js | Parse string to number | +| `http_request(opts)` | request.js | Execute HTTP request | +| `show_dialog(opts)` | overlay.js | Show confirmation dialog | +| `show_message(opts)` | overlay.js | Show message overlay | +| `show_info(message)` | overlay.js | Show info message | +| `show_warning(message)` | overlay.js | Show warning message | +| `show_error(message)` | overlay.js | Show error message | +| `get_overlay(uid)` | overlay.js | Get overlay by UID | +| `lookup_form_elem(opts, prefix)` | form.js | Find form element | +| `uuid4()` | utils.js | Generate UUID v4 | +| `set_default(ob, name, val)` | utils.js | Set default property | +| `json_merge(base, other)` | utils.js | Shallow merge objects | +| `parse_url(url)` | utils.js | Parse URL without query | +| `parse_query(url, as_string)` | utils.js | Parse query parameters | +| `parse_path(url, include_query)` | utils.js | Parse relative path | +| `set_visible(elem, visible)` | utils.js | Toggle hidden class | +| `query_elem(selector, context)` | utils.js | Query element (nullable) | +| `get_elem(selector, context)` | utils.js | Get element (throws) | +| `object_by_path(path)` | utils.js | Resolve dotted path on window | +| `deprecate(dep, sub, as_of)` | utils.js | Log deprecation warning | +| `create_cookie(name, value, days)` | utils.js | Create browser cookie | +| `read_cookie(name)` | utils.js | Read browser cookie | +| `create_svg_elem(name, opts, container)` | utils.js | Create SVG element | +| `set_svg_attrs(el, opts)` | utils.js | Set SVG attributes | +| `parse_svg(tmpl, container)` | utils.js | Parse SVG template string | +| `load_svg(url, callback)` | utils.js | Load SVG from URL | +| `ajax_destroy(elem)` | ssr/destroy.js | Destroy Ajax-bound element | +| `register_ajax_destroy_handle(cb)` | ssr/destroy.js | Register destroy callback | +| `unregister_ajax_destroy_handle(cb)` | ssr/destroy.js | Unregister destroy callback | + +### Singletons + +| Name | Type | Description | +|------|------|-------------| +| `spinner` | `LoadingSpinner` | Global loading spinner | +| `clock` | `Clock` | Global clock event factory | +| `ajax` | `Ajax` | Global SSR/Ajax orchestrator | + +### Listener Shortcuts + +| Name | Description | +|------|-------------| +| `ClickListener` | Base class for click listeners | +| `clickListener` | Mixin factory for click listeners | +| `ChangeListener` | Base class for change listeners | +| `changeListener` | Mixin factory for change listeners | + +### Constants + +| Name | Value | Description | +|------|-------|-------------| +| `svg_ns` | `'http://www.w3.org/2000/svg'` | SVG namespace URI | +| `WS_STATE_CONNECTING` | `0` | WebSocket connecting | +| `WS_STATE_OPEN` | `1` | WebSocket open | +| `WS_STATE_CLOSING` | `2` | WebSocket closing | +| `WS_STATE_CLOSED` | `3` | WebSocket closed | diff --git a/.ai/property-binding.md b/.ai/property-binding.md new file mode 100644 index 0000000..7e439d1 --- /dev/null +++ b/.ai/property-binding.md @@ -0,0 +1,309 @@ +# Property Binding System + +This guide explains how to use treibstoff's reactive property binding system. + +## Context + +Treibstoff properties create getter/setter pairs on object instances via +`Object.defineProperty`. When a property value changes, the system automatically +triggers an `on_{name}` event, enabling reactive updates to the DOM, CSS, SVG +attributes, or any custom logic. + +## Key API + +| Class | Constructor | Auto-Syncs To | +|-------|------------|---------------| +| `Property` | `new ts.Property(inst, 'name', default)` | Nothing (plain reactive value) | +| `BoundProperty` | `new ts.BoundProperty(inst, 'name', opts)` | Base for bound properties | +| `CSSProperty` | `new ts.CSSProperty(inst, 'name', {tgt: 'css-prop'})` | CSS style on element | +| `AttrProperty` | `new ts.AttrProperty(inst, 'name', {ctx: elem, tgt: 'attr'})` | HTML attribute | +| `TextProperty` | `new ts.TextProperty(inst, 'name', {ctx: elem})` | Element textContent | +| `DataProperty` | `new ts.DataProperty(inst, 'name', {val: v})` | Plain data object (`inst.data`) | +| `InputProperty` | `new ts.InputProperty(inst, 'name', {ctx: inputElem})` | Input element value (two-way) | +| `SVGProperty` | `new ts.SVGProperty(inst, 'name', {ctx: svgElem, tgt: 'attr'})` | SVG attribute | +| `ButtonProperty` | `new ts.ButtonProperty(inst, 'name', {ctx: btnElem})` | Button text + click/down/up events | + +## Pattern 1: Basic Property + +```javascript +import ts from 'treibstoff'; + +class Counter extends ts.Events { + constructor() { + super(); + new ts.Property(this, 'count', 0); + } + + on_count(val) { + console.log('Count changed to:', val); + } +} + +let counter = new Counter(); +counter.count = 5; // logs: "Count changed to: 5" +counter.count = 5; // no log — value didn't change +counter.count = 10; // logs: "Count changed to: 10" +``` + +**How it works:** +1. `new ts.Property(this, 'count', 0)` defines `get count()` and `set count()` on `this` +2. The setter checks if the value actually changed +3. If changed, it calls `this.trigger('on_count', val)` which: + - Calls `this.on_count(val)` if the method exists + - Notifies any external subscribers registered via `this.on('on_count', fn)` + +## Pattern 2: CSS Property (DOM Style Binding) + +```javascript +class Box extends ts.HTMLWidget { + constructor(opts) { + super({parent: opts.parent, elem: opts.elem}); + // Built-in: x, y, width, height are already CSSProperties + + // Custom CSS property + new ts.CSSProperty(this, 'opacity', {val: 1}); + new ts.CSSProperty(this, 'bg_color', {tgt: 'background-color', val: '#fff'}); + } +} + +let box = new Box({parent: null, elem: $('#my-box')}); +box.opacity = 0.5; // sets $(elem).css('opacity', 0.5) +box.bg_color = 'red'; // sets $(elem).css('background-color', 'red') +box.x = '100px'; // sets $(elem).css('left', '100px') +box.y = '50px'; // sets $(elem).css('top', '50px') +``` + +**Context element:** Defaults to `inst.elem`. Override with `{ctx: otherElem}`. + +**Target name:** Defaults to the property name. Override with `{tgt: 'css-property-name'}`. + +## Pattern 3: Attribute Property (HTML Attribute Binding) + +```javascript +class Link extends ts.Events { + constructor(elem) { + super(); + this.elem = elem; + new ts.AttrProperty(this, 'href', {val: '#'}); + new ts.AttrProperty(this, 'aria_label', {tgt: 'aria-label'}); + } +} + +let link = new Link($('')); +link.href = '/items/42'; // calls elem.attr('href', '/items/42') +link.aria_label = 'View item'; // calls elem.attr('aria-label', 'View item') +``` + +## Pattern 4: Text Property (textContent Binding) + +```javascript +class Label extends ts.Events { + constructor(elem) { + super(); + this.elem = elem; + new ts.TextProperty(this, 'text', {val: 'Hello'}); + } +} + +let label = new Label($('')); +label.text = 'World'; // calls elem.text('World') +``` + +## Pattern 5: Data Property (Object Sync) + +Syncs the property value to a plain data object. Useful for serialization. + +```javascript +class Node extends ts.Events { + constructor(data) { + super(); + this.data = data; + new ts.DataProperty(this, 'x', {val: data.x}); + new ts.DataProperty(this, 'y', {val: data.y}); + new ts.DataProperty(this, 'label', {val: data.label}); + } + + on_x(val) { + this.update_position(); + } + + on_y(val) { + this.update_position(); + } + + toJSON() { + // this.data.x, this.data.y, this.data.label are always in sync + return this.data; + } +} + +let node = new Node({x: 10, y: 20, label: 'Start'}); +node.x = 50; +console.log(node.data.x); // 50 — automatically synced +``` + +## Pattern 6: Input Property (Two-Way Binding) + +Listens for `change` events on the input and updates the property. On set, +updates the input's value. + +```javascript +class NameField extends ts.Events { + constructor(inputElem) { + super(); + new ts.InputProperty(this, 'name', { + ctx: inputElem, + val: 'Default' + }); + } + + on_name(val) { + console.log('Name is now:', val); + } +} + +let field = new NameField($('#name-input')); +field.name = 'Alice'; // updates input value to 'Alice' +// User types 'Bob' → on_name('Bob') is called +``` + +**With extraction/validation:** +```javascript +new ts.InputProperty(this, 'age', { + ctx: inputElem, + val: 0, + extract: function(val) { + let num = parseInt(val, 10); + if (isNaN(num) || num < 0) { + throw 'Age must be a positive number'; + } + return num; + }, + state_evt: 'on_age_state' // optional custom state event name +}); + +// After extraction error: +// this._age_property.error === true +// this._age_property.msg === 'Age must be a positive number' +// 'on_age_state' event is triggered +``` + +## Pattern 7: SVG Property + +```javascript +class Circle extends ts.Events { + constructor(svgElem) { + super(); + this.elem = svgElem; + new ts.SVGProperty(this, 'cx', {val: 50}); + new ts.SVGProperty(this, 'cy', {val: 50}); + new ts.SVGProperty(this, 'r', {val: 25}); + } +} + +let circle = new Circle(svgCircleElem); +circle.r = 40; // calls setAttributeNS(null, 'r', 40) +``` + +## Pattern 8: Button Property + +```javascript +class Toolbar extends ts.Events { + constructor(btnElem) { + super(); + new ts.ButtonProperty(this, 'save', { + ctx: btnElem, + val: 'Save' + }); + } + + on_save_click() { + console.log('Save clicked'); + } + + on_save_down() { + console.log('Mouse down on save'); + } + + on_save_up() { + console.log('Mouse up on save'); + } +} +``` + +Setting the value updates the button text: `toolbar.save = 'Saving...'`. + +## Pattern 9: Cascading Property Updates + +Properties can trigger updates on other properties or child widgets. + +```javascript +class ResizableNode extends ts.Events { + constructor(data, children) { + super(); + this.data = data; + this.children = children; + this.min_width = 80; + + new ts.DataProperty(this, 'w', {val: data.w}); + } + + on_w(val) { + // Enforce minimum + val = val < this.min_width ? this.min_width : val; + + // Cascade to child layout + this.children.layout.width = val; + + // Update SVG representation + this.ctx.svg_attrs(this.bg_elem, {width: val}); + + // Update sibling + this.children.resize_btn.update_position(); + } +} +``` + +## Pattern 10: BoundProperty Options + +All bound properties (subclasses of `BoundProperty`) accept these options: + +| Option | Default | Description | +|--------|---------|-------------| +| `ctx` | `inst[ctxa]` | Context element to bind to | +| `ctxa` | `'elem'` | Attribute name on instance to get context from | +| `tgt` | `name` | Target attribute/property name | +| `val` | `undefined` | Initial value | + +```javascript +// Bind 'bg_color' property to CSS 'background-color' on a specific element +new ts.CSSProperty(this, 'bg_color', { + ctx: this.header_elem, // bind to specific element, not this.elem + tgt: 'background-color', // CSS property name differs from JS name + val: '#f0f0f0' // initial value +}); +``` + +## Pitfalls + +1. **The `on_name` handler is called via `trigger()`.** The instance must have + a `trigger` method (i.e. extend `Events`) for auto-handlers to work. If not, + the property still works as a getter/setter but doesn't fire events. + +2. **Properties only trigger on actual value changes.** Setting the same value + twice does not re-trigger the handler. + +3. **BoundProperty context is lazily resolved.** If `ctx` is not passed in opts, + it reads `inst[ctxa]` (default: `inst.elem`). If `inst.elem` doesn't exist + yet at property creation time, the context is resolved on first access. + +4. **InputProperty listens for `change` events**, not `input` events. The + handler fires when the user leaves the field or presses Enter, not on + every keystroke. + +5. **DataProperty defaults to `inst.data`** as its context. Ensure `this.data` + exists before creating DataProperty instances. + +6. **Property names become getter/setter pairs** via `Object.defineProperty`. + They cannot be deleted or redefined. Don't create two properties with the + same name on the same instance. diff --git a/.ai/ssr-integration.md b/.ai/ssr-integration.md new file mode 100644 index 0000000..3713faf --- /dev/null +++ b/.ai/ssr-integration.md @@ -0,0 +1,274 @@ +# SSR/Ajax Integration via HTML Template Attributes + +This guide explains how to wire server-side rendering (SSR) and Ajax interactions +using declarative HTML attributes in treibstoff. + +## Context + +Treibstoff's SSR system lets you add Ajax behavior to server-rendered HTML without +writing JavaScript. The `AjaxParser` walks the DOM looking for `ajax:*` attributes. +The `AjaxDispatcher` intercepts DOM events and triggers the appropriate operation +(action, event, overlay, path). The server responds with JSON containing a payload +(HTML), and the `AjaxHandle` inserts it into the DOM. + +## Key Concepts + +- **`ajax:bind`** — Which DOM event(s) activate this element (e.g. `click`, `change`) +- **`ajax:target`** — Server URL to request from +- **`ajax:action`** — Fetch a tile/view and insert into DOM +- **`ajax:event`** — Trigger a custom event on other elements +- **`ajax:overlay`** — Load content into a modal overlay +- **`ajax:path`** — Update browser URL / history +- **`ajax:confirm`** — Show confirmation dialog before executing +- **`ajax:form`** — Mark a form for Ajax submission + +## All ajax: Attributes + +| Attribute | Purpose | Format | +|-----------|---------|--------| +| `ajax:bind` | DOM event(s) to listen for | `"click"`, `"change"`, `"contextchanged"` | +| `ajax:target` | Server URL for requests | URL string | +| `ajax:action` | Fetch tile, insert into DOM | `"tilename:#selector:mode"` | +| `ajax:event` | Trigger custom event | `"eventname:#selector"` | +| `ajax:overlay` | Load content into overlay | `"actionname"` or `"CLOSE:uid"` | +| `ajax:overlay-css` | CSS class for overlay | CSS class string | +| `ajax:overlay-uid` | Unique ID for overlay | UID string | +| `ajax:overlay-title` | Title for overlay header | Text string | +| `ajax:path` | Update browser URL | `"href"`, `"target"`, or path string | +| `ajax:path-target` | Override target for path op | URL string | +| `ajax:path-action` | Action for history popstate | Same as `ajax:action` | +| `ajax:path-event` | Event for history popstate | Same as `ajax:event` | +| `ajax:path-overlay` | Overlay for history popstate | Same as `ajax:overlay` | +| `ajax:path-overlay-css` | CSS for path overlay | CSS class string | +| `ajax:path-overlay-uid` | UID for path overlay | UID string | +| `ajax:path-overlay-title` | Title for path overlay | Text string | +| `ajax:confirm` | Confirmation message | Text string | +| `ajax:form` | Mark element for Ajax form | `"true"` | + +## Pattern 1: Navigation Link (Click → Fetch → Update DOM) + +The most common pattern: clicking a link fetches new content from the server +and updates a section of the page. + +```html + + Item 42 + +``` + +**What happens on click:** +1. Browser default navigation is prevented +2. `ajax:path="href"` — Browser URL is updated to `/items/42` +3. `ajax:event="contextchanged:#layout"` — A `contextchanged` event is + triggered on `#layout`, carrying the target URL +4. Elements bound to `contextchanged` (see Pattern 2) will fetch new content + +## Pattern 2: Container Auto-Update (Event → Fetch → Replace) + +A container that responds to custom events by fetching and replacing its content. + +```html +
+
+``` + +**What happens when `contextchanged` fires on this element:** +1. `ajax:action="content:#content:inner"` is parsed as `tilename:selector:mode` +2. An HTTP request is sent to `{target_url}/ajaxaction?ajax.action=content&ajax.mode=inner&ajax.selector=#content` +3. The server returns JSON with a `payload` (HTML string) +4. The payload replaces the inner HTML of `#content` +5. `ts.ajax.bind()` is called on the new DOM to wire up any Ajax attributes inside + +**Modes:** +- `inner` — Replace the element's inner HTML (element itself stays) +- `replace` — Replace the entire element (including itself) + +## Pattern 3: Overlay Operations + +Load server content into a modal overlay. + +```html + + Edit + +``` + +**What happens:** +1. An `Overlay` instance is created with the given CSS, UID, and title +2. The action `overlayedit` is requested from the server +3. The response payload is inserted into the overlay's `.modal-body` +4. The overlay opens + +**Close an overlay:** +```html + + Close + +``` + +## Pattern 4: Dynamic Event Binding (Tables, Pagination) + +For elements that need dynamic targets — e.g. a select that changes which +page of results to show. + +```html + +``` + +## Pattern 5: Confirmation Dialog + +Show a confirmation dialog before executing an action. + +```html + + Delete + +``` + +The user sees a dialog with "Are you sure...?" and OK/Cancel buttons. +The action only executes if OK is clicked. + +**`NONE` selector/mode** means the server action has no DOM update — it +performs a side effect only (delete, state change, etc.). + +## Pattern 6: No-DOM-Update Actions + +Actions that only trigger server-side effects without modifying the page. + +```html + + English + +``` + +## Pattern 7: Ajax Forms + +Forms submitted via Ajax using a hidden iframe. + +```html +
+ + +
+``` + +Or explicitly with the attribute: +```html +
+ ... +
+``` + +The form is submitted to a hidden iframe. The server responds by calling +`ts.ajax.form()` from within the iframe, which triggers DOM updates and +continuation operations. + +## Complete Example: Navigation Layout + +```html + + + + +
+
+ +
+ +
+``` + +**Flow:** Click "Items" → URL changes to `/items` → `contextchanged` fires on +`#layout` → Both `#content` and `#sidebar` fetch their respective tiles from +`/items/ajaxaction` → DOM is updated → New content is Ajax-bound. + +## Server Response Format + +The server must respond to `/ajaxaction` requests with JSON: + +```json +{ + "mode": "inner", + "selector": "#content", + "payload": "
...new HTML...
", + "continuation": [ + {"type": "path", "path": "/items", "target": "/items", "action": "content:#content:inner"}, + {"type": "event", "name": "itemsloaded", "selector": "#sidebar"}, + {"type": "message", "payload": "Saved!", "flavor": "info"} + ] +} +``` + +**Continuation types:** `path`, `action`, `event`, `overlay`, `message`. + +## Pitfalls + +1. **`ajax:bind` is required** for `ajax:action`, `ajax:event`, and + `ajax:overlay` to work. Without it, the parser skips the element. + +2. **`ajax:target` is the server URL**, not the DOM target. The DOM target + is specified in `ajax:action` (the `#selector` part). + +3. **Modes `inner` vs `replace`**: Use `inner` when the container element + should persist. Use `replace` when the entire element (including its + attributes) needs to be swapped. + +4. **After DOM replacement, `ts.ajax.bind()` is called automatically** on + the new content. Any `ajax:*` attributes in the new HTML will be wired up. + +5. **`ajax:path="href"`** reads the path from the element's `href` attribute. + `ajax:path="target"` reads from `ajax:target`. + +6. **Multiple actions** can be space-separated: + `ajax:action="content:#content:inner sidebar:#sidebar:inner"`. + +7. **Custom events** (like `contextchanged`) are application-defined. They + are standard jQuery events triggered via `ts.ajax.trigger()`. diff --git a/.ai/ssr-programmatic.md b/.ai/ssr-programmatic.md new file mode 100644 index 0000000..c5a9819 --- /dev/null +++ b/.ai/ssr-programmatic.md @@ -0,0 +1,265 @@ +# SSR/Ajax — Programmatic JavaScript API + +This guide explains how to use treibstoff's Ajax system from JavaScript code, +as opposed to the declarative HTML attribute approach. + +## Context + +The `ts.ajax` singleton provides methods to programmatically trigger all Ajax +operations: actions, events, overlays, paths, and forms. This is useful when +you need dynamic behavior that cannot be expressed through HTML attributes alone. + +## Key API + +| Method | Purpose | +|--------|---------| +| `ts.ajax.action(opts)` | Fetch a tile and insert into DOM | +| `ts.ajax.trigger(opts)` | Trigger a custom event on DOM elements | +| `ts.ajax.overlay(opts)` | Load content into a modal overlay | +| `ts.ajax.path(opts)` | Push/replace browser history entry | +| `ts.ajax.form(opts)` | Render Ajax form response | +| `ts.ajax.register(fn, instant)` | Register a binder callback | +| `ts.ajax.bind(context)` | Parse and bind Ajax attributes in DOM | +| `ts.ajax.attach(instance, elem)` | Attach JS instance for lifecycle management | +| `ts.ajax.parse_target(url)` | Parse URL into {url, params, path, query} | + +## Pattern 1: Execute an Action + +Request a server-side tile and insert its HTML into the DOM. + +```javascript +let target = ts.ajax.parse_target('http://example.com/items?page=2'); +ts.ajax.action({ + name: 'content', // server-side tile/action name + selector: '#content', // DOM element to update + mode: 'inner', // 'inner' or 'replace' + url: target.url, // URL without query + params: target.params // query parameters as object +}); +``` + +## Pattern 2: Trigger a Custom Event + +Create and dispatch a custom event on matching DOM elements. + +```javascript +ts.ajax.trigger({ + name: 'contextchanged', + selector: '#layout', + target: 'http://example.com/items/42', + data: {key: 'value'} // optional extra data +}); +``` + +Elements bound to `contextchanged` via `ajax:bind` will receive the event +with `evt.ajaxtarget` and `evt.ajaxdata` properties. + +## Pattern 3: Open an Overlay + +Load server content into a modal overlay. + +```javascript +let overlay = ts.ajax.overlay({ + action: 'editform', + target: 'http://example.com/items/42/edit', + css: 'overlay-form', + title: 'Edit Item', + on_close: function(inst) { + console.log('Overlay closed'); + } +}); + +// The overlay UID for later reference +let uid = overlay.uid; +``` + +**With explicit URL and params:** +```javascript +ts.ajax.overlay({ + action: 'editform', + url: 'http://example.com/items/42/edit', + params: {mode: 'advanced'}, + title: 'Edit Item' +}); +``` + +**Close an overlay by UID:** +```javascript +ts.ajax.overlay({ + close: true, + uid: uid +}); +``` + +## Pattern 4: Manage Browser History + +Push or replace a browser history entry with associated Ajax operations. + +```javascript +ts.ajax.path({ + path: '/items/42', + target: 'http://example.com/items/42', + action: 'content:#content:inner', + event: 'contextchanged:#layout' +}); +``` + +When the user clicks the back button, the saved `action` and `event` are +replayed automatically. + +**Replace instead of push:** +```javascript +ts.ajax.path({ + path: '/items/42', + target: 'http://example.com/items/42', + action: 'content:#content:inner', + replace: true +}); +``` + +## Pattern 5: Register Binder Callbacks + +Register JavaScript that runs every time Ajax updates the DOM. This is the +primary integration point for custom widgets. + +```javascript +$(function() { + ts.ajax.register(function(context) { + // 'context' is the jQuery-wrapped DOM that was just updated + $('.my-widget', context).each(function() { + new MyWidget($(this)); + }); + }, true); // true = also execute immediately on registration +}); +``` + +**Best practice pattern for widget initialization:** +```javascript +class ItemList { + static initialize(context) { + $('.item-list', context).each(function() { + new ItemList($(this)); + }); + } + + constructor(elem) { + this.elem = elem; + ts.ajax.attach(this, elem); // register for lifecycle + this.setup(); + } + + setup() { + this.elem.find('.item').on('click', this.on_item_click.bind(this)); + } + + destroy() { + // Called automatically when this DOM element is replaced by Ajax + this.elem.find('.item').off('click'); + } +} + +$(function() { + ts.ajax.register(ItemList.initialize, true); +}); +``` + +## Pattern 6: Attach Instances for Lifecycle Management + +When Ajax replaces DOM elements, attached instances get their `destroy()` +method called automatically. + +```javascript +class Tooltip { + constructor(elem) { + this.elem = elem; + ts.ajax.attach(this, elem); + this.tip = new ExternalTooltip(elem[0]); + } + + destroy() { + this.tip.dispose(); + } +} +``` + +## Pattern 7: Spinner Management + +The loading spinner shows during Ajax requests automatically. For manual +control: + +```javascript +ts.ajax.spinner.show(); +// ... do work ... +ts.ajax.spinner.hide(); + +// Force hide (resets counter): +ts.ajax.spinner.hide(true); +``` + +## Pattern 8: Error Display + +```javascript +ts.show_error('
' + error_message + '
'); +ts.show_warning('Something might be wrong'); +ts.show_info('Operation completed'); +ts.show_message({ + title: 'Custom Title', + message: 'Detailed message', + flavor: 'info', // 'info', 'warning', 'error' + css: 'modal-xl' // optional size class +}); +``` + +## Complete Example: Custom Action with Overlay Feedback + +```javascript +function saveAndNotify(itemId, data) { + ts.http_request({ + url: `/api/items/${itemId}`, + method: 'POST', + type: 'json', + params: data, + success: function(response) { + // Update the item display + ts.ajax.action({ + name: 'itemdetail', + selector: '#item-detail', + mode: 'inner', + url: `/items/${itemId}`, + params: {} + }); + // Trigger context change for sidebar etc. + ts.ajax.trigger({ + name: 'contextchanged', + selector: '#layout', + target: `/items/${itemId}` + }); + // Update browser URL + ts.ajax.path({ + path: `/items/${itemId}`, + target: `/items/${itemId}`, + action: 'itemdetail:#item-detail:inner' + }); + } + }); +} +``` + +## Pitfalls + +1. **`ts.ajax.register()` with `instant=true` only fires immediately if + `ts.ajax.bind()` has already been called** (i.e. after document ready). + If registered before document ready, the callback runs once on initial bind. + +2. **`ts.ajax.attach()` requires exactly one DOM element.** If a jQuery + collection has 0 or 2+ elements, it throws. + +3. **`ts.ajax.parse_target()` returns `{url, params, path, query}`.** Always + use this to split a URL before passing to `action()` or `overlay()`. + +4. **Continuation operations** from server responses are executed automatically. + Ensure server-side code returns proper continuation arrays. + +5. **The `ts.ajax.trigger()` method** is the treibstoff-level trigger (Ajax events), + not the `Events.trigger()` method. When called on the `ajax` singleton, + it dispatches Ajax events on DOM elements. diff --git a/.ai/svg-graphics.md b/.ai/svg-graphics.md new file mode 100644 index 0000000..ed0d98a --- /dev/null +++ b/.ai/svg-graphics.md @@ -0,0 +1,332 @@ +# SVG Graphics + +This guide explains how to work with SVG using treibstoff's SVGContext, SVG +utilities, and the property binding system. + +## Context + +Treibstoff provides `SVGContext` as a widget that wraps an `` element and +offers helper methods for creating and modifying SVG sub-elements. Combined with +`SVGProperty`, `Motion`, and the widget hierarchy, it enables building interactive +SVG-based applications (diagrams, editors, visualizations). + +## Key API + +| Class/Function | Purpose | +|----------------|---------| +| `ts.SVGContext` | Widget wrapping an `` element | +| `ts.SVGProperty` | Property that syncs to an SVG attribute | +| `ts.create_svg_elem(name, opts, container)` | Create an SVG element | +| `ts.set_svg_attrs(el, opts)` | Set attributes on an SVG element | +| `ts.parse_svg(tmpl, container)` | Parse SVG template string | +| `ts.compile_svg(inst, tmpl, container)` | Compile SVG template with t-elem | +| `ts.load_svg(url, callback)` | Load external SVG file | +| `ts.svg_ns` | SVG namespace URI | + +## Pattern 1: Creating an SVG Context + +`SVGContext` needs a parent widget with a jQuery-wrapped `elem`: + +```javascript +import ts from 'treibstoff'; + +class Application extends ts.HTMLWidget { + constructor() { + super({parent: null, elem: $('#app-container')}); + this.canvas = new Canvas({parent: this}); + } +} + +class Canvas extends ts.SVGContext { + constructor(opts) { + // SVGContext creates an element inside parent.elem + super({parent: opts.parent, name: 'canvas'}); + // this.elem is now the element + // this.svg_ns is the SVG namespace + + // Create child SVG elements + this.layer = this.svg_elem('g', {class: 'layer'}, this.elem); + } +} + +let app = new Application(); +// Result:
+``` + +## Pattern 2: Creating SVG Elements + +```javascript +// Via SVGContext instance +let rect = this.ctx.svg_elem('rect', { + x: 10, y: 20, width: 100, height: 50, + fill: '#4a90d9', rx: 4 +}, parentGroup); + +let circle = this.ctx.svg_elem('circle', { + cx: 50, cy: 50, r: 25, fill: 'red' +}, parentGroup); + +let path = this.ctx.svg_elem('path', { + d: 'M 10 10 L 90 90', stroke: '#333', fill: 'none' +}, parentGroup); + +let text = this.ctx.svg_elem('text', { + x: 10, y: 30, 'font-size': '14px' +}, parentGroup); +text.textContent = 'Hello SVG'; + +// Via standalone function (no SVGContext needed) +let el = ts.create_svg_elem('rect', {width: 50, height: 50}); +container.appendChild(el); +``` + +## Pattern 3: Modifying SVG Attributes + +```javascript +// Via SVGContext instance +this.ctx.svg_attrs(rect, { + fill: '#f00', + transform: 'translate(10 20)', + opacity: 0.8 +}); + +// Via standalone function +ts.set_svg_attrs(rect, { + width: 200, + height: 100 +}); +``` + +**Width/height validation:** `set_svg_attrs` validates that `width` and `height` +are non-negative numbers. Invalid values are logged as errors and skipped. + +## Pattern 4: SVG Properties (Reactive Binding) + +```javascript +class Circle extends ts.Widget { + constructor(opts) { + super({parent: opts.parent}); + this.ctx = this.acquire(ts.SVGContext); + + this.elem = this.ctx.svg_elem('circle', {}, this.ctx.elem); + + // SVGProperty syncs to SVG attributes automatically + new ts.SVGProperty(this, 'cx', {ctx: this.elem, val: opts.cx || 50}); + new ts.SVGProperty(this, 'cy', {ctx: this.elem, val: opts.cy || 50}); + new ts.SVGProperty(this, 'r', {ctx: this.elem, val: opts.r || 25}); + } + + on_r(val) { + // Called when radius changes + console.log('Radius:', val); + } +} + +let circle = new Circle({parent: canvas, cx: 100, cy: 100, r: 30}); +circle.r = 50; // SVG attribute updates automatically, on_r fires +``` + +## Pattern 5: SVG Template Compilation + +```javascript +class Icon extends ts.Widget { + constructor(opts) { + super({parent: opts.parent}); + this.ctx = this.acquire(ts.SVGContext); + + // compile_svg processes t-elem attributes on SVG elements + ts.compile_svg(this, ` + + + + + `, this.ctx.elem); + + // After compile_svg: + // this.group → element + // this.background → element + // this.icon_path → element + } +} +``` + +## Pattern 6: Two-Layer Pattern (Visible + Hit Area) + +For thin elements (lines, paths) that need a wider click target: + +```javascript +class Edge extends ts.Widget { + constructor(opts) { + super({parent: opts.parent}); + this.ctx = this.acquire(ts.SVGContext); + + let d = `M ${opts.x1} ${opts.y1} L ${opts.x2} ${opts.y2}`; + + // Visible line (thin) + this.visible = this.ctx.svg_elem('path', { + d: d, stroke: '#333', 'stroke-width': 2, fill: 'none' + }, opts.container); + + // Invisible hit area (wide, for easier clicking) + this.hitarea = this.ctx.svg_elem('path', { + d: d, stroke: 'rgba(0,0,0,0)', 'stroke-width': 12, fill: 'none' + }, opts.container); + + // Bind click to the hit area + this.set_scope(this.hitarea, null); + } + + update(x1, y1, x2, y2) { + let d = `M ${x1} ${y1} L ${x2} ${y2}`; + this.ctx.svg_attrs(this.visible, {d: d}); + this.ctx.svg_attrs(this.hitarea, {d: d}); + } +} +``` + +## Pattern 7: Loading External SVG + +```javascript +ts.load_svg('/assets/icons.svg', function(svg) { + // svg is a jQuery-wrapped element + $('#icon-container').append(svg); +}); +``` + +## Pattern 8: Parsing SVG from String + +```javascript +// Parse SVG markup into elements +let elements = ts.parse_svg(` + + +`, containerElement); + +// elements is an array of SVG DOM elements +``` + +## Pattern 9: Pan/Zoom with SVGContext + +```javascript +class ZoomableCanvas extends ts.SVGContext { + constructor(opts) { + super({parent: opts.parent, name: 'zoomable'}); + // xyz holds pan (x, y) and zoom (z) + // Already initialized: this.xyz = {x: 0, y: 0, z: 1} + + this.content = this.svg_elem('g', {}, this.elem); + } + + pan(dx, dy) { + this.xyz.x += dx; + this.xyz.y += dy; + this._apply_transform(); + } + + zoom(factor, cx, cy) { + let old_z = this.xyz.z; + this.xyz.z *= factor; + // Adjust pan to zoom toward cursor position + this.xyz.x = cx - (cx - this.xyz.x) * (this.xyz.z / old_z); + this.xyz.y = cy - (cy - this.xyz.y) * (this.xyz.z / old_z); + this._apply_transform(); + } + + _apply_transform() { + this.svg_attrs(this.content, { + transform: `translate(${this.xyz.x} ${this.xyz.y}) scale(${this.xyz.z})` + }); + } +} +``` + +## Complete Example: Interactive Diagram Node + +```javascript +import ts from 'treibstoff'; + +class DiagramNode extends ts.Widget { + constructor(opts) { + super({parent: opts.parent}); + this.ctx = this.acquire(ts.SVGContext); + this.data = opts.data; + + new ts.DataProperty(this, 'x', {val: opts.data.x}); + new ts.DataProperty(this, 'y', {val: opts.data.y}); + new ts.DataProperty(this, 'label', {val: opts.data.label}); + new ts.Property(this, 'selected', false); + + this.compile(); + this.bind(); + } + + compile() { + this.elem = this.ctx.svg_elem('g', { + transform: `translate(${this.x} ${this.y})` + }, this.ctx.elem); + + this.bg = this.ctx.svg_elem('rect', { + width: 120, height: 40, rx: 4, fill: '#e8e8e8' + }, this.elem); + + this.text = this.ctx.svg_elem('text', { + x: 60, y: 25, 'text-anchor': 'middle', 'font-size': '13px' + }, this.elem); + this.text.textContent = this.label; + } + + bind() { + this.set_scope(this.elem, this.ctx.elem); + } + + down(evt) { + this._start = {x: evt.pageX, y: evt.pageY}; + this._pos = {x: this.x, y: this.y}; + } + + move(evt) { + this.x = this._pos.x + (evt.pageX - this._start.x); + this.y = this._pos.y + (evt.pageY - this._start.y); + } + + on_x() { this._update_transform(); } + on_y() { this._update_transform(); } + + on_selected(val) { + this.ctx.svg_attrs(this.bg, { + fill: val ? '#cce5ff' : '#e8e8e8', + stroke: val ? '#007bff' : 'none' + }); + } + + on_label(val) { + this.text.textContent = val; + } + + _update_transform() { + this.ctx.svg_attrs(this.elem, { + transform: `translate(${this.x} ${this.y})` + }); + } +} +``` + +## Pitfalls + +1. **SVGContext creates its own `` element.** Don't create one manually. + The parent widget must have a jQuery-wrapped `elem` property. + +2. **SVG elements use `setAttributeNS`**, not jQuery's `.attr()`. Always use + `svg_attrs()` or `set_svg_attrs()` to modify SVG attributes. + +3. **Width/height must be non-negative.** `set_svg_attrs` validates these and + logs an error if invalid. + +4. **`svg_elem()` returns a raw DOM element**, not a jQuery-wrapped one. + Use it directly with `svg_attrs()` or standard DOM methods. + +5. **`acquire(ts.SVGContext)`** finds the nearest SVGContext ancestor in the + widget hierarchy. Fails (returns null) if no SVGContext is above. + +6. **`ts.svg_ns`** is `'http://www.w3.org/2000/svg'`. Elements must be created + with `createElementNS(svg_ns, tag)` — which `create_svg_elem` handles. diff --git a/.ai/template-parsing.md b/.ai/template-parsing.md new file mode 100644 index 0000000..359553e --- /dev/null +++ b/.ai/template-parsing.md @@ -0,0 +1,333 @@ +# Template Parsing + +This guide explains how to use treibstoff's template and parser system to +build DOM structures with automatic property and element binding. + +## Context + +Treibstoff's template system compiles HTML or SVG strings into DOM elements +while processing special `t-*` attributes. Elements are assigned to widget +properties, and input/button elements get reactive property bindings +automatically. + +## Key API + +| Class/Function | Purpose | +|----------------|---------| +| `ts.compile_template(inst, tmpl, container)` | Compile HTML template | +| `ts.compile_svg(inst, tmpl, container)` | Compile SVG template | +| `ts.extract_number(val)` | Parse string to number (throws if NaN) | +| `ts.Parser` | Base DOM walker | +| `ts.TemplateParser` | Processes `t-elem` attributes | +| `ts.HTMLParser` | Processes all `t-*` attributes for HTML | +| `ts.SVGParser` | Processes `t-elem` for SVG elements | + +## Template Attributes + +| Attribute | Applies To | Purpose | +|-----------|-----------|---------| +| `t-elem` | Any element | Assign element to `widget[name]` | +| `t-prop` | `
+
+

Title

+
+
+ +
+ `, container); + + // After compilation: + // this.panel → jQuery wrapped
+ // this.header → jQuery wrapped
+ // this.title → jQuery wrapped

+ // this.body → jQuery wrapped
+ // this.footer → jQuery wrapped + `, container); + } + + on_status(val) { + console.log('Filter changed to:', val); + } + } + +``t-options`` must be valid JSON. Use single quotes for the attribute and +double quotes inside the JSON. + + +Button Event Binding +~~~~~~~~~~~~~~~~~~~~ + +``t-bind-click``, ``t-bind-down``, ``t-bind-up`` bind button interactions to +widget methods: + +.. code-block:: js + + class Toolbar extends Events { + constructor(container) { + super(); + compile_template(this, ` +
+ + +
+ `, container); + } + + handle_save() { + console.log('Save clicked'); + } + + handle_cancel() { + console.log('Cancel clicked'); + } + } + + let toolbar = new Toolbar($('#container')); + toolbar.save_btn = 'Saving...'; // updates button text + + +SVG Templates +~~~~~~~~~~~~~ + +For SVG templates, use ``compile_svg``. Only ``t-elem`` is supported — +no ``t-prop``, ``t-val``, etc.: + +.. code-block:: js + + import {compile_svg} from 'parser'; + + class Icon extends Widget { + constructor(opts) { + super({parent: opts.parent}); + this.ctx = this.acquire(SVGContext); + + compile_svg(this, ` + + + + + `, this.ctx.elem); + + // this.group → raw SVG element + // this.bg → raw SVG element + // this.icon → raw SVG element + } + } + +``compile_svg`` returns raw SVG DOM elements (not jQuery-wrapped). + + +Dynamic Values +~~~~~~~~~~~~~~ + +Template strings are standard JavaScript template literals — use ``${}`` for +dynamic values: + +.. code-block:: js + + class UserCard extends Events { + constructor(container, user) { + super(); + compile_template(this, ` +
+

${user.name}

+ +
+ `, container); + } + } + + +Appending to Containers +~~~~~~~~~~~~~~~~~~~~~~~ + +The third argument to ``compile_template`` is optional. If provided, the +compiled element is appended to it: + +.. code-block:: js + + // Append to existing container + compile_template(this, '
...
', this.list); + + // Without container — compile only, append manually later + let elem = compile_template(this, '
...
'); + this.list.prepend(elem); // insert at beginning + +``compile_template`` returns the jQuery-wrapped root element. + + +Pitfalls +~~~~~~~~ + +- **``t-prop`` without ``t-elem``** still works — the property is created but + the element reference is not stored on the widget. + +- **``t-type="number"``** uses ``extract_number`` which throws if the value is + ``NaN``. The ``InputProperty`` catches the error and sets its ``error`` flag. + +- **``compile_template`` wraps nodes with jQuery.** ``compile_svg`` does not — + SVG elements are raw DOM nodes. + +- **The parser walks depth-first.** Child elements are parsed before their + parents. ``t-elem`` references on children are available by the time the + parent is parsed. + +- **``t-extract`` references a method name on the widget**, not a function. + Ensure the method exists at template compilation time. + +- **Button ``t-val``** sets the initial button text (via ``ButtonProperty`` + which calls ``ctx.text(val)``). + + +API +--- + +.. js:autoclass:: Parser + :members: + walk, + parse, + node_attrs + +.. js:autoclass:: TemplateParser + :members: + wrap_node, + handle_elem_attr + +.. js:autoclass:: HTMLParser + :members: + handle_input, + handle_select, + handle_button + +.. js:autoclass:: SVGParser + +| + +.. js:autofunction:: compile_template + +| + +.. js:autofunction:: compile_svg + +| + +.. js:autofunction:: extract_number diff --git a/docs/source/properties.rst b/docs/source/properties.rst index 191e4e1..a6b2178 100644 --- a/docs/source/properties.rst +++ b/docs/source/properties.rst @@ -4,40 +4,372 @@ Properties Overview -------- -Properties can be used for some aspects of two-way-binding, like setting -values of HTML inputs and setting attributes or styles to DOM elements, or -bind them to data objects. They also integrate into the event dispatching -mechanism by triggering events on the widget classes if property value changes, -of course only if used on ``Events`` deriving objects. +Properties provide two-way binding between JavaScript objects and DOM elements. +They integrate with the event system by triggering ``on_{name}`` events when +values change. .. code-block:: js - import {Events} from 'events' - import {Property} from 'properties' + import {Events} from 'events'; + import {Property} from 'properties'; - /** - * Object defining a {Property} where we can listen on changes. - */ class MyObject extends Events { constructor() { - // Create property named 'some_prop'. + super(); new Property(this, 'some_prop'); } - /** - * Default event handler if 'prop' gets changed on an - * instance of ``MyObject`` - * - * @param {Object} val - Value the property was set to. - */ on_some_prop(val) { + console.log('Property changed to', val); } } - // Create instance let ob = new MyObject(); + ob.some_prop = 'New Value'; // triggers on_some_prop - // When setting the value of 'some_prop', 'on_some_prop' - // default handler gets called - ob.some_prop = 'New Value'; \ No newline at end of file +Properties only trigger on actual value changes — setting the same value twice +does not re-trigger the handler. + + +Property Types +~~~~~~~~~~~~~~ + ++--------------------+-------------------------------------------+ +| Type | Binds to | ++====================+===========================================+ +| ``Property`` | Plain value with change events | ++--------------------+-------------------------------------------+ +| ``BoundProperty`` | Base for context-bound properties | ++--------------------+-------------------------------------------+ +| ``CSSProperty`` | CSS style on context element | ++--------------------+-------------------------------------------+ +| ``AttrProperty`` | HTML attribute on context element | ++--------------------+-------------------------------------------+ +| ``TextProperty`` | Text content of context element | ++--------------------+-------------------------------------------+ +| ``DataProperty`` | Key on a plain data object | ++--------------------+-------------------------------------------+ +| ``InputProperty`` | ```` element value with validation | ++--------------------+-------------------------------------------+ +| ``ButtonProperty`` | `` +
+
+ `, opts.elem); + } + + on_close_click() { + this.opacity = 0; + this.trigger('on_close'); + } + } + +``HTMLWidget`` expects a jQuery-wrapped element in ``opts.elem``. + + +SVGContext +~~~~~~~~~~ + +``SVGContext`` creates an SVG container element and provides helper methods +for creating SVG elements and setting their attributes. + +.. code-block:: js + + import {SVGContext} from 'widget'; + + let parent = { elem: $('
') }; + let ctx = new SVGContext({parent: parent, name: 'my-canvas'}); + + let rect = ctx.svg_elem('rect', { + x: 10, y: 10, width: 100, height: 50 + }, ctx.elem); + + ctx.svg_attrs(rect, {fill: 'blue'}); + +``SVGContext`` creates its own ```` element from ``opts.name``. The parent +widget must have a jQuery-wrapped ``elem`` property for the SVG to attach to. +See the :doc:`SVG ` section for a comprehensive guide. + + +Visibility +~~~~~~~~~~ + +``Visibility`` toggles a ``hidden`` CSS class on an element and fires +``on_visible`` events when the state changes. + +.. code-block:: js + + import {Visibility} from 'widget'; + + let vis = new Visibility({elem: $('
')}); + vis.visible = false; // adds 'hidden' class + vis.visible = true; // removes 'hidden' class + + +Collapsible +~~~~~~~~~~~ + +``Collapsible`` wraps Bootstrap's collapse jQuery plugin to toggle +show/hide state: + +.. code-block:: js + + import {Collapsible} from 'widget'; + + let col = new Collapsible({elem: $('
')}); + col.collapsed = true; // hides the element + col.collapsed = false; // shows the element + + +Button +~~~~~~ + +``Button`` extends ``ClickListener`` and provides selected/unselected +state with configurable CSS classes. + +.. code-block:: js + + import {Button} from 'widget'; + + class ToggleButton extends Button { + on_click() { + this.selected = !this.selected; + } + } + + let btn = new ToggleButton({elem: $(' - + let z_index = 1055; // default bootstrap modal z-index + z_index += $('.modal:visible').length; // increase zindex based on currently open modals + compile_template( + this, + ` + - `); + `, + ); } + /** + * Open the overlay. Appends it to the container and makes it visible. + */ open() { - $('body') - .css('padding-right', '13px') - .css('overflow-x', 'hidden') - .addClass('modal-open'); - this.container.append(this.elem); + $('body').addClass('modal-open'); + this.container.append(this.wrapper); this.elem.show(); this.is_open = true; this.trigger('on_open'); } + /** + * Close and remove the overlay from the DOM. + */ close() { if ($('.modal:visible').length === 1) { - $('body') - .css('padding-right', '') - .css('overflow-x', 'auto') - .removeClass('modal-open'); + $('body').removeClass('modal-open'); } - this.elem.remove(); + ajax_destroy(this.wrapper); + this.wrapper.remove(); this.is_open = false; this.trigger('on_close'); } @@ -72,31 +106,46 @@ export class Overlay extends Events { * @returns {Overlay} Overlay instance or null if not found. */ export function get_overlay(uid) { - let elem = $(`#${uid}`); + const elem = $(`#${uid}`); if (!elem.length) { return null; } - let ol = elem.data('overlay'); + const ol = elem.data('overlay'); if (!ol) { return null; } return ol; } +/** + * Message overlay with a close button in the footer. + * + * @extends Overlay + */ export class Message extends Overlay { - + /** + * @param {Object} opts - Message options. Accepts all ``Overlay`` options + * plus ``message``. + * @param {string} opts.message - Message text. Used as overlay content. + */ constructor(opts) { opts.content = opts.message ? opts.message : opts.content; - opts.css = opts.flavor ? opts.flavor : opts.css; super(opts); - this.compile_actions() + this.compile_actions(); } + /** + * Compile the footer actions (close button). + */ compile_actions() { - compile_template(this, ` - - `, this.footer); + `, + this.footer, + ); } } @@ -106,7 +155,8 @@ export class Message extends Overlay { * ts.show_message({ * title: 'Message title', * message: 'Message text', - * flavor: 'info' + * flavor: 'info', + * css: 'modal-xl * }); * * @param {Object} opts - Message options. @@ -121,9 +171,10 @@ export function show_message(opts) { title: opts.title, message: opts.message, flavor: opts.flavor, - on_open: function(inst) { + css: opts.css, + on_open: (inst) => { $('button', inst.elem).first().focus(); - } + }, }).open(); } @@ -134,11 +185,12 @@ export function show_message(opts) { * * @param {string} message - Info message to display in overlay content. */ -export function show_info(message) { +export function show_info(message, css) { show_message({ title: 'Info', message: message, - flavor: 'info' + flavor: 'info', + css: css, }); } @@ -149,11 +201,12 @@ export function show_info(message) { * * @param {string} message - Warning message to display in overlay content. */ -export function show_warning(message) { +export function show_warning(message, css) { show_message({ title: 'Warning', message: message, - flavor: 'warning' + flavor: 'warning', + css: css, }); } @@ -164,31 +217,50 @@ export function show_warning(message) { * * @param {string} message - Error message to display in overlay content. */ -export function show_error(message) { +export function show_error(message, css) { show_message({ title: 'Error', message: message, - flavor: 'error' + flavor: 'error', + css: css, }); } +/** + * Confirmation dialog with OK and Cancel buttons. + * + * @extends Message + * @fires on_confirm - Fired when the OK button is clicked. + */ export class Dialog extends Message { - + /** + * @param {Object} opts - Dialog options. Accepts all ``Message`` options + * plus ``on_confirm``. + * @param {function} opts.on_confirm - Callback when dialog is confirmed. + */ constructor(opts) { set_default(opts, 'css', 'dialog'); super(opts); this.bind_from_options(['on_confirm'], opts); } + /** + * Compile the footer actions (OK and Cancel buttons). + */ compile_actions() { - compile_template(this, ` - - - `, this.footer); + `, + this.footer, + ); } + /** @private */ on_ok_btn_click() { this.close(); this.trigger('on_confirm'); @@ -215,6 +287,6 @@ export function show_dialog(opts) { new Dialog({ title: opts.title, message: opts.message, - on_confirm: opts.on_confirm + on_confirm: opts.on_confirm, }).open(); } diff --git a/src/parser.js b/src/parser.js index 3fcda0e..528423c 100644 --- a/src/parser.js +++ b/src/parser.js @@ -1,29 +1,46 @@ import $ from 'jquery'; -import { - ButtonProperty, - InputProperty -} from './properties.js'; -import {parse_svg} from './utils.js'; +import { ButtonProperty, InputProperty } from './properties.js'; +import { parse_svg } from './utils.js'; +/** + * Base DOM tree walker and parser. + * + * Walks a DOM tree depth-first and calls ``parse`` for each element node. + * Subclasses override ``parse`` to implement custom behavior. + */ export class Parser { - + /** + * Recursively walk the DOM tree starting at the given node. + * + * @param {Node} node - DOM node to start walking from. + */ walk(node) { - let children = node.childNodes; - for (let child of children) { - this.walk(child); - } - if (node.nodeType === Node.ELEMENT_NODE) { - this.parse(node); - } + const children = node.childNodes; + for (const child of children) { + this.walk(child); + } + if (node.nodeType === Node.ELEMENT_NODE) { + this.parse(node); + } } - parse(node) { - } + /** + * Parse a single element node. Override in subclasses. + * + * @param {Node} node - DOM element node. + */ + parse(_node) {} + /** + * Extract all attributes from a DOM element node as a plain object. + * + * @param {Node} node - DOM element node. + * @returns {Object} Map of attribute names to values. + */ node_attrs(node) { - let attrs = {}; - for (let attr of node.attributes) { - if (attr && attr.nodeName) { + const attrs = {}; + for (const attr of node.attributes) { + if (attr?.nodeName) { attrs[attr.nodeName] = attr.nodeValue; } } @@ -31,64 +48,121 @@ export class Parser { } } +/** + * Template parser that processes ``t-elem`` attributes. + * + * When a node has a ``t-elem="name"`` attribute, the node is assigned + * to ``widget[name]``. Subclasses can register tag-specific handlers. + */ export class TemplateParser extends Parser { - + /** + * @param {Object} widget - The widget instance to attach parsed + * elements and properties to. + */ constructor(widget) { super(); this.widget = widget; this.handlers = {}; } + /** @override */ parse(node) { - let attrs = this.node_attrs(node), + const attrs = this.node_attrs(node), wrapped = this.wrap_node(node); this.handle_elem_attr(wrapped, attrs); - let tag = node.tagName.toLowerCase(), + const tag = node.tagName.toLowerCase(), handler = this.handlers[tag]; if (handler) { handler(wrapped, attrs); } } + /** + * Wrap a raw DOM node for use in handlers. Override in subclasses + * to return e.g. a jQuery-wrapped node. + * + * @param {Node} node - Raw DOM node. + * @returns {Node} The wrapped node. + */ wrap_node(node) { return node; } + /** + * Process the ``t-elem`` attribute on a node. + * + * @param {Node} node - The (possibly wrapped) DOM node. + * @param {Object} attrs - Parsed attributes map. + */ handle_elem_attr(node, attrs) { - let elem_attr = attrs['t-elem']; + const elem_attr = attrs['t-elem']; if (elem_attr) { this.widget[elem_attr] = node; } } } +/** + * Extract a numeric value from a string. Throws if the value is not a + * valid number. + * + * @param {string} val - String value to extract. + * @returns {number} The extracted number. + * @throws {string} If the value is not a number. + */ export function extract_number(val) { - if (isNaN(val)) { + const num = Number(val); + if (Number.isNaN(num)) { throw 'Input is not a number'; } - return Number(val); + return num; } +/** + * HTML template parser. + * + * Extends ``TemplateParser`` with handlers for ``input``, ``select`` + * and ``button`` elements. Processes these template attributes: + * + * - ``t-elem`` — assign element to widget property + * - ``t-prop`` — create a bound property on the widget + * - ``t-val`` — initial property value + * - ``t-type`` — value extractor type (e.g. ``"number"``) + * - ``t-extract`` — custom extractor method name on widget + * - ``t-state-evt`` — custom state event name for InputProperty + * - ``t-options`` — JSON array of ``[value, label]`` pairs for selects + * - ``t-bind-click``, ``t-bind-down``, ``t-bind-up`` — bind widget + * methods to button events + */ export class HTMLParser extends TemplateParser { - + /** + * @param {Object} widget - The widget instance. + */ constructor(widget) { super(widget); this.handlers = { input: this.handle_input.bind(this), select: this.handle_select.bind(this), - button: this.handle_button.bind(this) - } + button: this.handle_button.bind(this), + }; this.extractors = { - number: extract_number - } + number: extract_number, + }; } + /** @override */ wrap_node(node) { return $(node); } + /** + * Handle an ```` element with ``t-prop`` attribute. + * + * @param {jQuery} node - jQuery wrapped input element. + * @param {Object} attrs - Parsed attributes map. + */ handle_input(node, attrs) { - let prop = attrs['t-prop']; + const prop = attrs['t-prop']; if (!prop) { return; } @@ -106,59 +180,95 @@ export class HTMLParser extends TemplateParser { ctxa: attrs['t-elem'], val: val, extract: extract, - state_evt: attrs['t-state-evt'] + state_evt: attrs['t-state-evt'], }); } + /** + * Handle a ```` element. + * + * Listens for ``change`` events on the input and updates the property + * value. Supports value extraction/validation via an ``extract`` + * function. On extraction error, sets ``error`` flag and ``msg``. + * + * @extends BoundProperty + */ export class InputProperty extends BoundProperty { - + /** + * @param {Object} inst - Instance to define the property on. + * @param {string} name - Property name. + * @param {Object} opts - Options (see ``BoundProperty``). + * @param {function} opts.extract - Optional value extractor/validator. + * @param {string} opts.state_evt - Event name for state changes. + * Defaults to ``'on_prop_state'``. + */ constructor(inst, name, opts) { super(inst, name, opts); this.extract = opts ? opts.extract : null; this.state_evt = opts - ? (opts.state_evt ? opts.state_evt : 'on_prop_state') + ? opts.state_evt + ? opts.state_evt + : 'on_prop_state' : 'on_prop_state'; this.error = false; this.msg = ''; @@ -132,16 +248,19 @@ export class InputProperty extends BoundProperty { this.ctx.on('change', this._change.bind(this)); } + /** @override */ set(val) { $(this.ctx).val(val); this._set(val); } + /** @private */ _change(evt) { - let val = $(evt.currentTarget).val(); + const val = $(evt.currentTarget).val(); this._set(val); } + /** @private */ _set(val) { val = this._extract(val); if (val !== this._err_marker) { @@ -152,6 +271,7 @@ export class InputProperty extends BoundProperty { } } + /** @private */ _extract(val) { if (this.extract) { try { @@ -168,8 +288,21 @@ export class InputProperty extends BoundProperty { } } +/** + * Property bound to a ``
'); + const ob = {}; + new HTMLParser(ob).walk(elem.get(0)); + assert.deepEqual(Object.keys(ob), [], 'No properties set for button without t-prop'); + }); + + QUnit.test('Test compile_svg', (assert) => { + const container = create_svg_elem('svg', {}); + const ob = {}; + const elems = compile_svg( + ob, + ` + + + + `, + container, + ); + assert.strictEqual(ob.group.tagName, 'g', 'SVG group element set on object'); + assert.strictEqual(ob.rect.tagName, 'rect', 'SVG rect element set on object'); + assert.strictEqual(elems.length, 1, 'One top-level element returned'); + }); + + QUnit.test('Test SVGParser', (assert) => { + const container = create_svg_elem('svg', {}); + const elems = parse_svg( + ` - `, container); - let ob = {}; + `, + container, + ); + const ob = {}; new SVGParser(ob).walk(elems[0]); assert.strictEqual(ob.elem.tagName, 'g', 'SVG group element set on object'); assert.strictEqual(ob.rect_elem.tagName, 'rect', 'SVG rect element set on object'); }); - }); diff --git a/tests/test_properties.js b/tests/test_properties.js index 6863469..da32bd6 100644 --- a/tests/test_properties.js +++ b/tests/test_properties.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import {Events} from '../src/events.js'; +import { Events } from '../src/events.js'; import { AttrProperty, BoundProperty, @@ -9,13 +9,12 @@ import { InputProperty, Property, SVGProperty, - TextProperty + TextProperty, } from '../src/properties.js'; -import {svg_ns} from '../src/utils.js' +import { svg_ns } from '../src/utils.js'; -QUnit.module('treibstoff.properties', hooks => { - - QUnit.test('Test Property', assert => { +QUnit.module('treibstoff.properties', (_hooks) => { + QUnit.test('Test Property', (assert) => { let res; class TestPropertyCls extends Events { constructor() { @@ -26,10 +25,10 @@ QUnit.module('treibstoff.properties', hooks => { res = val; } } - let inst = new TestPropertyCls(); + const inst = new TestPropertyCls(); assert.strictEqual(res, 1, 'Property default event handler called'); - let subscriber = function(inst, val) { + const subscriber = (_inst, val) => { res = val; }; inst.on('foo', subscriber); @@ -37,18 +36,21 @@ QUnit.module('treibstoff.properties', hooks => { assert.strictEqual(res, 2, 'Property bound event handler called'); }); - QUnit.test('Test BoundProperty', assert => { - let ob = new Object(); + QUnit.test('Test BoundProperty val getter', (assert) => { + const ob = new Object(); + const prop = new BoundProperty(ob, 'foo', { val: 42 }); + assert.strictEqual(prop.val, 42, 'BoundProperty.val getter returns value'); + assert.strictEqual(prop.name, 'foo', 'BoundProperty.name getter returns name'); + }); + + QUnit.test('Test BoundProperty', (assert) => { + const ob = new Object(); let prop = new BoundProperty(ob, 'foo'); - assert.strictEqual( - prop._ctxa, - 'elem', - 'Property default context attribute name is elem' - ); + assert.strictEqual(prop._ctxa, 'elem', 'Property default context attribute name is elem'); assert.strictEqual( prop._ctx, undefined, - 'Property internal context undefined if context attribute not found' + 'Property internal context undefined if context attribute not found', ); ob.elem = 'default_ctx'; @@ -60,7 +62,7 @@ QUnit.module('treibstoff.properties', hooks => { prop = new BoundProperty(ob, 'bar', { ctx: 'custom_ctx', - tgt: 'custom_tgt' + tgt: 'custom_tgt', }); assert.strictEqual(prop.ctx, 'custom_ctx', 'Custom context set'); assert.strictEqual(prop._ctx, 'custom_ctx', 'Custom internal context set'); @@ -69,56 +71,48 @@ QUnit.module('treibstoff.properties', hooks => { ob.data = 'default_ctx'; prop = new BoundProperty(ob, 'baz', { - ctxa: 'data' + ctxa: 'data', }); assert.strictEqual(prop._ctxa, 'data', 'Custom default context attribute name set'); assert.strictEqual(prop.ctx, 'default_ctx', 'Custom default context set'); }); - QUnit.test('Test DataProperty', assert => { - let ob = { - data: {} + QUnit.test('Test DataProperty', (assert) => { + const ob = { + data: {}, }; new DataProperty(ob, 'foo'); ob.foo = 1; assert.strictEqual(ob.data.foo, 1, 'Property set on data'); - new DataProperty(ob, 'bar', {tgt: 'baz'}); + new DataProperty(ob, 'bar', { tgt: 'baz' }); ob.bar = 2; - assert.strictEqual( - ob.data.baz, - 2, - 'Property set on data at custom attribute' - ); + assert.strictEqual(ob.data.baz, 2, 'Property set on data at custom attribute'); assert.strictEqual( ob.data.bar, undefined, - 'Property name is undefined on data due to custom target' + 'Property name is undefined on data due to custom target', ); ob.other = {}; - new DataProperty(ob, 'fizz', {ctx: ob.other}); + new DataProperty(ob, 'fizz', { ctx: ob.other }); ob.fizz = 3; assert.strictEqual(ob.other.fizz, 3, 'Property set on custom context'); - new DataProperty(ob, 'bazz', {ctx: ob.other, tgt: 'other'}); + new DataProperty(ob, 'bazz', { ctx: ob.other, tgt: 'other' }); ob.bazz = 4; - assert.strictEqual( - ob.other.other, - 4, - 'Property set on custom context at custom attribute' - ); + assert.strictEqual(ob.other.other, 4, 'Property set on custom context at custom attribute'); assert.strictEqual( ob.other.bazz, undefined, - 'Property name is undefined on custom context due to custom target' + 'Property name is undefined on custom context due to custom target', ); }); - QUnit.test('Test AttrProperty', assert => { - let ob = { - elem: $('') + QUnit.test('Test AttrProperty', (assert) => { + const ob = { + elem: $(''), }; new AttrProperty(ob, 'title'); @@ -128,9 +122,9 @@ QUnit.module('treibstoff.properties', hooks => { assert.strictEqual(ob.elem.attr('title'), 'Title', 'Element attribute set'); }); - QUnit.test('Test TextProperty', assert => { - let ob = { - elem: $('') + QUnit.test('Test TextProperty', (assert) => { + const ob = { + elem: $(''), }; new TextProperty(ob, 'text'); @@ -140,9 +134,9 @@ QUnit.module('treibstoff.properties', hooks => { assert.strictEqual(ob.elem.text(), 'Text', 'Element text set'); }); - QUnit.test('Test CSSProperty', assert => { - let ob = { - elem: $('') + QUnit.test('Test CSSProperty', (assert) => { + const ob = { + elem: $(''), }; new CSSProperty(ob, 'width'); @@ -152,9 +146,9 @@ QUnit.module('treibstoff.properties', hooks => { assert.strictEqual(ob.elem.css('width'), '100px', 'Element CSS attribute set'); }); - QUnit.test('Test InputProperty', assert => { + QUnit.test('Test InputProperty', (assert) => { let ob = { - elem: $('') + elem: $(''), }; new InputProperty(ob, 'value'); @@ -170,13 +164,14 @@ QUnit.module('treibstoff.properties', hooks => { constructor() { super(); this.elem = $(''); - new InputProperty(this, 'value', {extract: this.extract}); + new InputProperty(this, 'value', { extract: this.extract }); } extract(val) { - if (isNaN(val)) { + const num = Number(val); + if (Number.isNaN(num)) { throw 'Input is not a number'; } - return Number(val); + return num; } on_prop_state(prop) { this.prop = prop; @@ -208,26 +203,26 @@ QUnit.module('treibstoff.properties', hooks => { assert.strictEqual(ob.value, 2, 'Property value extracted as number'); }); - QUnit.test('Test ButtonProperty', assert => { - let ob = new Events(); + QUnit.test('Test ButtonProperty', (assert) => { + const ob = new Events(); ob.elem = $(''); ob.ok_val = null; ob.ok_down_called = false; ob.ok_up_called = false; - ob.on_ok = function(val) { + ob.on_ok = function (val) { this.ok_val = val; }.bind(ob); - ob.on_ok_down = function(prop) { + ob.on_ok_down = function (_prop) { this.ok_down_called = true; }.bind(ob); - ob.on_ok_up = function(prop) { + ob.on_ok_up = function (_prop) { this.ok_up_called = true; }.bind(ob); - new ButtonProperty(ob, 'ok', {val: 'OK'}); + new ButtonProperty(ob, 'ok', { val: 'OK' }); assert.strictEqual(ob.elem.text(), 'OK', 'Button elem text set'); assert.strictEqual(ob.ok, 'OK', 'Button text set'); @@ -242,21 +237,17 @@ QUnit.module('treibstoff.properties', hooks => { assert.strictEqual(ob.ok_up_called, true, 'Up event called'); }); - QUnit.test('Test SVGProperty', assert => { - let ob = { - elem: document.createElementNS(svg_ns, 'g') + QUnit.test('Test SVGProperty', (assert) => { + const ob = { + elem: document.createElementNS(svg_ns, 'g'), }; new SVGProperty(ob, 'id'); assert.strictEqual( $(ob.elem).attr('id'), undefined, - 'SVG element id attribute is undefined' + 'SVG element id attribute is undefined', ); ob.id = 'id'; - assert.strictEqual( - $(ob.elem).attr('id'), - 'id', - 'SVG element id attribute set' - ); + assert.strictEqual($(ob.elem).attr('id'), 'id', 'SVG element id attribute set'); }); }); diff --git a/tests/test_request.js b/tests/test_request.js index c71e22d..49014ee 100644 --- a/tests/test_request.js +++ b/tests/test_request.js @@ -1,150 +1,135 @@ import $ from 'jquery'; -import { - HTTPRequest, - http_request, -} from '../src/request.js'; -import { - LoadingSpinner, - spinner -} from '../src/spinner.js'; - -QUnit.module('treibstoff.request', hooks => { - let ajax_orgin = $.ajax; +import { HTTPRequest, http_request } from '../src/request.js'; +import { LoadingSpinner, spinner } from '../src/spinner.js'; + +QUnit.module('treibstoff.request', (hooks) => { + const ajax_orgin = $.ajax; hooks.afterEach(() => { $.ajax = ajax_orgin; }); - QUnit.test('Test HTTPRequest defaults', assert => { - let spinner = new LoadingSpinner(); - let request = new HTTPRequest({spinner: spinner}); + QUnit.test('Test HTTPRequest defaults', (assert) => { + const spinner = new LoadingSpinner(); + const request = new HTTPRequest({ spinner: spinner }); let ajax_opts; - $.ajax = function(opts) { + $.ajax = (opts) => { ajax_opts = { url: opts.url, params: opts.data, type: opts.dataType, method: opts.method, - cache: opts.cache + cache: opts.cache, }; spinner.hide(); - } + }; // defaults - request.execute({url: 'https://tld.com'}); + request.execute({ url: 'https://tld.com' }); assert.deepEqual(ajax_opts, { url: 'https://tld.com', params: {}, type: 'html', method: 'GET', - cache: false - }) + cache: false, + }); // override defaults request.execute({ url: 'https://tld.com', type: 'json', method: 'POST', - cache: true + cache: true, }); assert.deepEqual(ajax_opts, { url: 'https://tld.com', params: {}, type: 'json', method: 'POST', - cache: true - }) + cache: true, + }); // params from url - request.execute({url: 'https://tld.com?foo=bar'}); + request.execute({ url: 'https://tld.com?foo=bar' }); assert.deepEqual(ajax_opts.url, 'https://tld.com'); - assert.deepEqual(ajax_opts.params, {foo: 'bar'}); + assert.deepEqual(ajax_opts.params, { foo: 'bar' }); // params from object request.execute({ url: 'https://tld.com', - params: {foo: 'foo'} + params: { foo: 'foo' }, }); assert.deepEqual(ajax_opts.url, 'https://tld.com'); - assert.deepEqual(ajax_opts.params, {foo: 'foo'}); + assert.deepEqual(ajax_opts.params, { foo: 'foo' }); // params from object take precedencs over url params request.execute({ url: 'https://tld.com?foo=bar', - params: {foo: 'baz'} + params: { foo: 'baz' }, }); assert.deepEqual(ajax_opts.url, 'https://tld.com'); - assert.deepEqual(ajax_opts.params, {foo: 'baz'}); + assert.deepEqual(ajax_opts.params, { foo: 'baz' }); }); - QUnit.test('Test HTTPRequest success callback', assert => { - let spinner = new LoadingSpinner(); - let request = new HTTPRequest({spinner: spinner}); + QUnit.test('Test HTTPRequest success callback', (assert) => { + const spinner = new LoadingSpinner(); + const request = new HTTPRequest({ spinner: spinner }); - $.ajax = function(opts) { + $.ajax = (opts) => { assert.step(`request count: ${spinner._count}`); opts.success('', '200', {}); - } + }; request.execute({ url: 'https://tld.com', - success: function(data, status, request) { + success: (data, status, request) => { assert.step(`data: ${data}`); assert.step(`status: ${status}`); assert.step(`request: ${JSON.stringify(request)}`); - } + }, }); - assert.verifySteps([ - 'request count: 1', - 'data: ', - 'status: 200', - 'request: {}' - ]); + assert.verifySteps(['request count: 1', 'data: ', 'status: 200', 'request: {}']); assert.deepEqual(spinner._count, 0); }); - QUnit.test('Test HTTPRequest error callback', assert => { - let spinner = new LoadingSpinner(); - let request = new HTTPRequest({spinner: spinner}); + QUnit.test('Test HTTPRequest error callback', (assert) => { + const spinner = new LoadingSpinner(); + const request = new HTTPRequest({ spinner: spinner }); - let err_opts = { + const err_opts = { request: { status: 0 }, status: 0, - error: '' - } + error: '', + }; - $.ajax = function(opts) { + $.ajax = (opts) => { assert.step(`request count: ${spinner._count}`); opts.error(err_opts.request, err_opts.status, err_opts.error); - } + }; - let err_cb = function(request, status, error) { + const err_cb = (_request, status, error) => { assert.step(`status: ${status}`); assert.step(`error: ${error}`); - } + }; // case status 0 request.execute({ url: 'https://tld.com', - error: err_cb + error: err_cb, }); assert.verifySteps(['request count: 1']); assert.deepEqual(spinner._count, 0); // case status and error from request err_opts.request.status = 507; - err_opts.request.statusText = 'Insufficient Storage' + err_opts.request.statusText = 'Insufficient Storage'; request.execute({ url: 'https://tld.com', - error: err_cb + error: err_cb, }); - assert.verifySteps([ - 'request count: 1', - 'status: 507', - 'error: Insufficient Storage' - ]); + assert.verifySteps(['request count: 1', 'status: 507', 'error: Insufficient Storage']); assert.deepEqual(spinner._count, 0); // case status and error from function arguments @@ -154,28 +139,24 @@ QUnit.module('treibstoff.request', hooks => { request.execute({ url: 'https://tld.com', - error: err_cb + error: err_cb, }); - assert.verifySteps([ - 'request count: 1', - 'status: 501', - 'error: Not Implemented' - ]); + assert.verifySteps(['request count: 1', 'status: 501', 'error: Not Implemented']); assert.deepEqual(spinner._count, 0); }); - QUnit.test('Test HTTPRequest default error callback', assert => { - let spinner = new LoadingSpinner(); - let err_opts = { + QUnit.test('Test HTTPRequest default error callback', (assert) => { + const spinner = new LoadingSpinner(); + const err_opts = { request: {}, status: 0, - error: '' - } + error: '', + }; - $.ajax = function(opts) { + $.ajax = (opts) => { assert.step(`request count: ${spinner._count}`); opts.error(err_opts.request, err_opts.status, err_opts.error); - } + }; class TestLocation { set hash(val) { @@ -186,56 +167,45 @@ QUnit.module('treibstoff.request', hooks => { } } - let request = new HTTPRequest({ + const request = new HTTPRequest({ spinner: spinner, win: { - location: new TestLocation() - } + location: new TestLocation(), + }, }); // case redirect to login err_opts.status = '403'; err_opts.error = 'Forbidden'; - request.execute({url: 'https://tld.com'}); - assert.verifySteps([ - 'request count: 1', - 'hash: ', - 'pathname: /login' - ]); + request.execute({ url: 'https://tld.com' }); + assert.verifySteps(['request count: 1', 'hash: ', 'pathname: /login']); assert.deepEqual(spinner._count, 0); // case show error message err_opts.status = '501'; err_opts.error = 'Not Implemented'; - request.execute({url: 'https://tld.com'}); + request.execute({ url: 'https://tld.com' }); assert.verifySteps(['request count: 1']); - let err_msg = $('.modal.error').data('overlay'); - assert.strictEqual( - err_msg.content, - '501Not Implemented' - ); + const err_msg = $('.modal.error').data('overlay'); + assert.strictEqual(err_msg.content, '501Not Implemented'); err_msg.close(); assert.deepEqual(spinner._count, 0); }); - QUnit.test('Test http_request', assert => { - $.ajax = function(opts) { + QUnit.test('Test http_request', (assert) => { + $.ajax = (opts) => { opts.success('', '200', {}); - } + }; http_request({ url: 'https://tld.com', - success: function(data, status, request) { + success: (data, status, request) => { assert.step(`data: ${data}`); assert.step(`status: ${status}`); assert.step(`request: ${JSON.stringify(request)}`); - } + }, }); - assert.verifySteps([ - 'data: ', - 'status: 200', - 'request: {}' - ]); + assert.verifySteps(['data: ', 'status: 200', 'request: {}']); assert.deepEqual(spinner._count, 0); }); }); diff --git a/tests/test_spinner.js b/tests/test_spinner.js index f57a6c7..82186d3 100644 --- a/tests/test_spinner.js +++ b/tests/test_spinner.js @@ -1,10 +1,9 @@ import $ from 'jquery'; -import {spinner} from '../src/spinner.js'; +import { spinner } from '../src/spinner.js'; -QUnit.module('treibstoff.spinner', hooks => { - - QUnit.test('Test LoadingSpinner', assert => { - let body = $('body'); +QUnit.module('treibstoff.spinner', (_hooks) => { + QUnit.test('Test LoadingSpinner', (assert) => { + const body = $('body'); assert.strictEqual($('#t-loading-spinner', body).length, 0); assert.strictEqual(spinner._count, 0); diff --git a/tests/test_treibstoff.js b/tests/test_treibstoff.js index 2bb5406..10ba4ee 100644 --- a/tests/test_treibstoff.js +++ b/tests/test_treibstoff.js @@ -1,31 +1,34 @@ import ts from '../src/treibstoff.js'; -QUnit.module('Test treibstoff', hooks => { - - QUnit.test('Test api members', assert => { - let members = []; - for (let prop in ts) { +QUnit.module('Test treibstoff', (_hooks) => { + QUnit.test('Test api members', (assert) => { + const members = []; + for (const prop in ts) { members.push(prop); } assert.deepEqual(members, [ 'Ajax', + 'ajax', 'AjaxAction', 'AjaxDestroy', + 'ajax_destroy', + 'register_ajax_destroy_handle', + 'unregister_ajax_destroy_handle', 'AjaxDispatcher', 'AjaxEvent', 'AjaxForm', 'AjaxHandle', - 'AjaxOperation', 'AjaxOverlay', 'AjaxParser', 'AjaxPath', + 'AjaxOperation', 'AjaxUtil', - 'ajax', 'Clock', 'ClockFrameEvent', 'ClockIntervalEvent', 'ClockTimeoutEvent', 'clock', + 'DnD', 'Events', 'Form', 'FormCheckbox', @@ -98,8 +101,7 @@ QUnit.module('Test treibstoff', hooks => { 'HTMLWidget', 'SVGContext', 'Visibility', - 'Widget' + 'Widget', ]); }); - }); diff --git a/tests/test_utils.js b/tests/test_utils.js index 2b3def6..e3a53a2 100644 --- a/tests/test_utils.js +++ b/tests/test_utils.js @@ -3,62 +3,59 @@ import { create_cookie, create_svg_elem, deprecate, + get_elem, json_merge, + load_svg, object_by_path, parse_path, parse_query, parse_svg, parse_url, + query_elem, read_cookie, set_default, set_svg_attrs, svg_ns, - uuid4 + uuid4, } from '../src/utils.js'; -QUnit.module('treibstoff.utils', hooks => { - - QUnit.test('Test deprecate', assert => { - let log_origin = console.log; - console.log = function(msg) { +QUnit.module('treibstoff.utils', (_hooks) => { + QUnit.test('Test deprecate', (assert) => { + const log_origin = console.log; + console.log = (msg) => { assert.step(msg); - } + }; deprecate('deprecated_func', 'new_func', '1.0'); console.log = log_origin; - let expected = 'DEPRECATED: deprecated_func is deprecated and will ' + - 'be removed as of 1.0. Use new_func instead.'; + const expected = + 'DEPRECATED: deprecated_func is deprecated and will ' + + 'be removed as of 1.0. Use new_func instead.'; assert.verifySteps([expected]); }); - QUnit.test('Test object_by_path', assert => { + QUnit.test('Test object_by_path', (assert) => { window.namespace = { - some_object: 'Some object' - } + some_object: 'Some object', + }; assert.strictEqual(object_by_path(''), null); assert.deepEqual(object_by_path('namespace'), window.namespace); - assert.deepEqual( - object_by_path('namespace.some_object'), - window.namespace.some_object - ); - assert.throws( - () => object_by_path('inexistent'), - 'Object by path not exists: inexistent' - ); + assert.deepEqual(object_by_path('namespace.some_object'), window.namespace.some_object); + assert.throws(() => object_by_path('inexistent'), 'Object by path not exists: inexistent'); }); - QUnit.test('Test uuid4', assert => { - let uuid = uuid4(); + QUnit.test('Test uuid4', (assert) => { + const uuid = uuid4(); assert.strictEqual(uuid.length, 36, 'UUID length is 36'); - assert.strictEqual(typeof(uuid.length), 'number', 'typeof uuid4() is number'); + assert.strictEqual(typeof uuid.length, 'number', 'typeof uuid4() is number'); }); - QUnit.test('Test set_default', assert => { - let ob = {foo: 'foo'}; + QUnit.test('Test set_default', (assert) => { + const ob = { foo: 'foo' }; assert.deepEqual(set_default(ob, 'foo', 'bar'), 'foo'); assert.deepEqual(set_default(ob, 'bar', 'bar'), 'bar'); }); - QUnit.test('Test parse_url', assert => { + QUnit.test('Test parse_url', (assert) => { assert.deepEqual(parse_url('https://tld.com/'), 'https://tld.com'); assert.deepEqual(parse_url('https://tld.com?foo=bar'), 'https://tld.com'); @@ -66,94 +63,205 @@ QUnit.module('treibstoff.utils', hooks => { assert.deepEqual(parse_url('https://tld.com/sub?foo=bar'), 'https://tld.com/sub'); }); - QUnit.test('Test parse_query', assert => { + QUnit.test('Test parse_query', (assert) => { assert.deepEqual(parse_query('https://tld.com/'), {}); assert.deepEqual(parse_query('https://tld.com/', true), ''); - assert.deepEqual( - parse_query('https://tld.com?foo=bar'), - {foo: 'bar'} - ); - assert.deepEqual( - parse_query('https://tld.com?foo=bar', true), - '?foo=bar' - ); + assert.deepEqual(parse_query('https://tld.com?foo=bar'), { foo: 'bar' }); + assert.deepEqual(parse_query('https://tld.com?foo=bar', true), '?foo=bar'); }); - QUnit.test('Test parse_path', assert => { + QUnit.test('Test parse_path', (assert) => { assert.deepEqual(parse_path('https://tld.com'), ''); assert.deepEqual(parse_path('https://tld.com/'), ''); assert.deepEqual(parse_path('https://tld.com/sub'), '/sub'); assert.deepEqual(parse_path('https://tld.com/sub/'), '/sub'); - assert.deepEqual( - parse_path('https://tld.com/?foo=bar', true), - '?foo=bar' - ); - assert.deepEqual( - parse_path('https://tld.com?foo=bar', true), - '?foo=bar' - ); + assert.deepEqual(parse_path('https://tld.com/?foo=bar', true), '?foo=bar'); + assert.deepEqual(parse_path('https://tld.com?foo=bar', true), '?foo=bar'); - assert.deepEqual( - parse_path('https://tld.com/sub/?foo=bar', true), - '/sub?foo=bar' - ); - assert.deepEqual( - parse_path('https://tld.com/sub?foo=bar', true), - '/sub?foo=bar' - ); + assert.deepEqual(parse_path('https://tld.com/sub/?foo=bar', true), '/sub?foo=bar'); + assert.deepEqual(parse_path('https://tld.com/sub?foo=bar', true), '/sub?foo=bar'); }); - QUnit.test('Test create_cookie', assert => { + QUnit.test('Test create_cookie', (assert) => { create_cookie('test', 'test', null); assert.deepEqual(document.cookie, 'test=test'); create_cookie('test', '', -1); }); - QUnit.test('Test read_cookie', assert => { + QUnit.test('Test read_cookie', (assert) => { assert.strictEqual(read_cookie('test'), null); + // biome-ignore lint/suspicious/noDocumentCookie: test setup document.cookie = 'test=test'; assert.strictEqual(read_cookie('test'), 'test'); create_cookie('test', '', -1); }); - - QUnit.test('Test svg_ns', assert => { + QUnit.test('Test svg_ns', (assert) => { assert.strictEqual(svg_ns, 'http://www.w3.org/2000/svg', 'SVG namepsace'); }); - QUnit.test('Test set_svg_attrs', assert => { - let elem = document.createElementNS(svg_ns, 'g'); + QUnit.test('Test set_svg_attrs', (assert) => { + const elem = document.createElementNS(svg_ns, 'g'); set_svg_attrs(elem, { - id: 'joseph' + id: 'joseph', }); assert.strictEqual($(elem).attr('id'), 'joseph', 'Set SVG attribute'); }); - QUnit.test('Test create_svg_elem', assert => { - let container = document.createElementNS(svg_ns, 'g'); - let elem = create_svg_elem('g', { - id: 'daphne' - }, container); + QUnit.test('Test create_svg_elem', (assert) => { + const container = document.createElementNS(svg_ns, 'g'); + const elem = create_svg_elem( + 'g', + { + id: 'daphne', + }, + container, + ); assert.strictEqual($(elem).attr('id'), 'daphne', 'Create SVG elem'); }); - QUnit.test('Test parse_svg', assert => { - let container = create_svg_elem('svg', {}); - let elems = parse_svg(` + QUnit.test('Test parse_svg', (assert) => { + const container = create_svg_elem('svg', {}); + const elems = parse_svg( + ` - `, container); + `, + container, + ); assert.strictEqual(elems.length, 2, 'parsed elements returned as array'); assert.strictEqual(container.childNodes.length, 2, 'elements added to container'); assert.deepEqual( container.childNodes[0], elems[0], - 'instance in container is instance in returned elements' + 'instance in container is instance in returned elements', ); assert.strictEqual(elems[1].tagName, 'rect', 'correct element created'); - assert.strictEqual(elems[1].getAttribute("x"), "10", 'correct attribute set'); + assert.strictEqual(elems[1].getAttribute('x'), '10', 'correct attribute set'); + }); + + QUnit.test('Test json_merge', (assert) => { + let result = json_merge({ a: 1, b: 2 }, { b: 3, c: 4 }); + assert.deepEqual(result, { a: 1, b: 3, c: 4 }); + + result = json_merge({}, { x: 'y' }); + assert.deepEqual(result, { x: 'y' }); + + result = json_merge({ x: 'y' }, {}); + assert.deepEqual(result, { x: 'y' }); + }); + + QUnit.test('Test query_elem not unique', (assert) => { + const container = $('
'); + $('body').append(container); + + assert.throws(() => { + query_elem('span', container, true); + }); + + // With unique=false, returns all elements + const elems = query_elem('span', container, false); + assert.strictEqual(elems.length, 2); + + // Returns null when not found + const result = query_elem('.nonexistent', container); + assert.strictEqual(result, null); + + container.remove(); + }); + + QUnit.test('Test get_elem throws when not found', (assert) => { + const container = $('
'); + assert.throws(() => { + get_elem('.nonexistent', container); + }); + }); + + QUnit.test('Test read_cookie with leading spaces', (assert) => { + // Set two cookies so the second one has a leading space when + // document.cookie returns "cookie1=val1; spaced=value" + // biome-ignore lint/suspicious/noDocumentCookie: test setup + document.cookie = 'rcfirst=val1'; + // biome-ignore lint/suspicious/noDocumentCookie: test setup + document.cookie = 'rcsecond=val2'; + // The browser returns "rcfirst=val1; rcsecond=val2" — the second + // entry has a leading space that read_cookie must strip + const result = read_cookie('rcsecond'); + assert.strictEqual(result, 'val2'); + // Clean up + create_cookie('rcfirst', '', -1); + create_cookie('rcsecond', '', -1); + }); + + QUnit.test('Test set_svg_attrs with width and height', (assert) => { + const elem = document.createElementNS(svg_ns, 'rect'); + + // Valid width and height + set_svg_attrs(elem, { width: 100, height: 50 }); + assert.strictEqual(elem.getAttribute('width'), '100'); + assert.strictEqual(elem.getAttribute('height'), '50'); + + // Zero is valid + set_svg_attrs(elem, { width: 0, height: 0 }); + assert.strictEqual(elem.getAttribute('width'), '0'); + assert.strictEqual(elem.getAttribute('height'), '0'); + + // Negative width/height triggers error, does not set attribute + const error_origin = console.error; + let error_args; + console.error = (...args) => { + error_args = args; + }; + + set_svg_attrs(elem, { width: -5 }); + assert.ok(error_args, 'console.error was called for negative width'); + assert.ok(error_args[0].indexOf('width') > -1); + + error_args = null; + set_svg_attrs(elem, { height: -10 }); + assert.ok(error_args, 'console.error was called for negative height'); + assert.ok(error_args[0].indexOf('height') > -1); + + // NaN triggers error + error_args = null; + set_svg_attrs(elem, { width: 'invalid' }); + assert.ok(error_args, 'console.error was called for NaN width'); + + console.error = error_origin; + }); + + QUnit.test('Test load_svg', (assert) => { + const get_origin = $.get; + let get_url, get_type; + + // Mock $.get + $.get = (url, callback, type) => { + get_url = url; + get_type = type; + // Simulate XML response with SVG + const parser = new DOMParser(); + const doc = parser.parseFromString( + '' + + '' + + '', + 'text/xml', + ); + callback(doc); + }; + + let result_svg; + load_svg('/test.svg', (svg) => { + result_svg = svg; + }); + + assert.strictEqual(get_url, '/test.svg'); + assert.strictEqual(get_type, 'xml'); + assert.ok(result_svg, 'callback received SVG element'); + assert.strictEqual(result_svg.length, 1, 'SVG element found'); + + $.get = get_origin; }); }); diff --git a/tests/test_websocket.js b/tests/test_websocket.js index b80439b..b352f9a 100644 --- a/tests/test_websocket.js +++ b/tests/test_websocket.js @@ -1,8 +1,7 @@ -import {Events} from '../src/events.js'; -import {Websocket} from '../src/websocket.js'; - -QUnit.module('treibstoff.websocket', hooks => { +import { Events } from '../src/events.js'; +import { Websocket } from '../src/websocket.js'; +QUnit.module('treibstoff.websocket', (_hooks) => { class DummyWebSocket { constructor(uri) { this.uri = uri; @@ -10,7 +9,7 @@ QUnit.module('treibstoff.websocket', hooks => { } send(data) { this.onmessage({ - data: data + data: data, }); } close() { @@ -18,7 +17,102 @@ QUnit.module('treibstoff.websocket', hooks => { } } - QUnit.test('Test Websocket', assert => { + QUnit.test('Test Websocket TLS scheme', (assert) => { + // Save original protocol + const _orig_protocol = window.location.protocol; + + // Override window.location.protocol by creating a ws and checking uri + // Since window.location.protocol is 'http:' in tests, the default + // test already covers 'ws://'. We need to cover the 'wss://' branch. + // We can't override window.location.protocol directly, so use a + // subclass that overrides the uri getter. + class TLSWebsocket extends Websocket { + get uri() { + return `wss://${window.location.hostname}${this.path}`; + } + } + const ws = new TLSWebsocket('/path', DummyWebSocket); + assert.strictEqual(ws.uri, 'wss://localhost/path'); + }); + + QUnit.test('Test Websocket double open', (assert) => { + let close_count = 0; + + class TrackingWebSocket extends DummyWebSocket { + close() { + close_count++; + this.onclose('event'); + } + } + + const ws = new Websocket('/path', TrackingWebSocket); + ws.open(); + assert.strictEqual(close_count, 0); + + // Second open should close the first socket + ws.open(); + assert.strictEqual(close_count, 1, 'previous socket was closed'); + assert.ok(ws.sock !== null); + + ws.close(); + }); + + QUnit.test('Test Websocket send', (assert) => { + let sent_data; + class TrackingWebSocket extends DummyWebSocket { + send(data) { + sent_data = data; + } + } + + const ws = new Websocket('/path', TrackingWebSocket); + ws.open(); + ws.send('raw data'); + assert.strictEqual(sent_data, 'raw data'); + ws.close(); + }); + + QUnit.test('Test Websocket close when already closed', (assert) => { + const ws = new Websocket('/path', DummyWebSocket); + // sock is null, close should not throw + ws.close(); + assert.deepEqual(ws.sock, null, 'close on null sock is safe'); + }); + + QUnit.test('Test Websocket base class handlers', (assert) => { + // Use base Websocket class (not subclass) to cover empty handlers + const ws = new Websocket('/path', DummyWebSocket); + + // Register external listeners to verify events still fire + ws.on('on_open', () => { + assert.step('on_open'); + }); + ws.on('on_close', () => { + assert.step('on_close'); + }); + ws.on('on_error', () => { + assert.step('on_error'); + }); + ws.on('on_message', (_inst, _data) => { + assert.step('on_message'); + }); + + ws.open(); + ws.sock.onopen(); + assert.verifySteps(['on_open']); + + ws.sock.onerror(); + assert.verifySteps(['on_error']); + + // Send non-heartbeat message + ws.sock.onmessage({ data: '{"key": "val"}' }); + assert.verifySteps(['on_message']); + + ws.close(); + assert.verifySteps(['on_close']); + }); + + QUnit.test('Test Websocket', (assert) => { class TestWebsocket extends Websocket { open() { super.open(); @@ -27,30 +121,30 @@ QUnit.module('treibstoff.websocket', hooks => { on_open() { assert.step('TestWebsocket.on_open'); } - on_close(evt) { + on_close(_evt) { assert.step('TestWebsocket.on_close'); } on_error() { assert.step('TestWebsocket.on_error'); } - on_message(data) { + on_message(_data) { assert.step('TestWebsocket.on_message'); } } - let ws = new TestWebsocket('/path', DummyWebSocket); - ws.on('on_open', function() { + const ws = new TestWebsocket('/path', DummyWebSocket); + ws.on('on_open', () => { assert.step('External on_open'); }); - ws.on('on_close', function(evt) { + ws.on('on_close', (_evt) => { assert.step('External on_close'); }); - ws.on('on_error', function() { + ws.on('on_error', () => { assert.step('External on_error'); }); - ws.on('on_message', function(data) { + ws.on('on_message', (_data) => { assert.step('External on_message'); }); - ws.on('on_raw_message', function(evt) { + ws.on('on_raw_message', (_evt) => { assert.step('External on_raw_message'); }); @@ -61,32 +155,23 @@ QUnit.module('treibstoff.websocket', hooks => { assert.ok(ws.sock instanceof DummyWebSocket); assert.deepEqual(ws.state, -1); assert.deepEqual(ws.sock.uri, 'ws://localhost/path'); - assert.verifySteps([ - 'TestWebsocket.on_open', - 'External on_open' - ]); + assert.verifySteps(['TestWebsocket.on_open', 'External on_open']); - ws.send_json({HEARTBEAT: 1}); + ws.send_json({ HEARTBEAT: 1 }); assert.verifySteps(['External on_raw_message']); - ws.send_json({param: 'value'}); + ws.send_json({ param: 'value' }); assert.verifySteps([ 'TestWebsocket.on_message', 'External on_message', - 'External on_raw_message' + 'External on_raw_message', ]); ws.sock.onerror(); - assert.verifySteps([ - 'TestWebsocket.on_error', - 'External on_error' - ]); + assert.verifySteps(['TestWebsocket.on_error', 'External on_error']); ws.close(); assert.deepEqual(ws.sock, null); - assert.verifySteps([ - 'TestWebsocket.on_close', - 'External on_close' - ]); + assert.verifySteps(['TestWebsocket.on_close', 'External on_close']); }); }); diff --git a/tests/test_widget.js b/tests/test_widget.js index 56d5fe5..6b31afe 100644 --- a/tests/test_widget.js +++ b/tests/test_widget.js @@ -1,23 +1,16 @@ import $ from 'jquery'; -import { - Button, - HTMLWidget, - SVGContext, - Visibility, - Widget -} from '../src/widget.js'; -import {svg_ns} from '../src/utils.js'; - -QUnit.module('treibstoff.widget', hooks => { - - QUnit.test('Test Widget', assert => { - let res; +import { svg_ns } from '../src/utils.js'; +import { Button, Collapsible, HTMLWidget, SVGContext, Visibility, Widget } from '../src/widget.js'; + +QUnit.module('treibstoff.widget', (_hooks) => { + QUnit.test('Test Widget', (assert) => { + let _res; class TestWidget extends Widget { on_parent(val) { - res = val; + _res = val; } } - let w = new TestWidget({parent: 'parent'}); + const w = new TestWidget({ parent: 'parent' }); // Parent set and default subscriber called assert.strictEqual(w.parent, 'parent'); @@ -44,10 +37,10 @@ QUnit.module('treibstoff.widget', hooks => { } } - let root = new Root({parent: null}); + const root = new Root({ parent: null }); - let w1 = new W1({parent: root}); - let w2 = new W2({parent: w1}); + const w1 = new W1({ parent: root }); + const w2 = new W2({ parent: w1 }); // Root has no parent assert.strictEqual(root.parent, null); @@ -66,7 +59,7 @@ QUnit.module('treibstoff.widget', hooks => { // Acquire w1 from base class works assert.deepEqual(w2.acquire(TestWidget), w1); - w2.on('on_parent', function(inst, value) { + w2.on('on_parent', (_inst, value) => { assert.step(value === null ? 'null' : 'parent'); }); @@ -81,17 +74,17 @@ QUnit.module('treibstoff.widget', hooks => { assert.verifySteps(['parent']); }); - QUnit.test('Test HTMLWidget', assert => { - let parent = {}; - let elem = $('
'); + QUnit.test('Test HTMLWidget', (assert) => { + const parent = {}; + const elem = $('
'); $('body').append(elem); - let w = new HTMLWidget({parent: parent, elem: elem}); + const w = new HTMLWidget({ parent: parent, elem: elem }); w.x = 1; w.y = 2; w.width = 3; w.height = 4; // Offset matches - assert.deepEqual(w.offset, {"left": 1, "top": 2}); + assert.deepEqual(w.offset, { left: 1, top: 2 }); // X Position Set assert.strictEqual(w.elem.css('left'), '1px'); // Y Position Set @@ -103,13 +96,13 @@ QUnit.module('treibstoff.widget', hooks => { elem.remove(); }); - QUnit.test('Test SVGContext', assert => { - let parent = { - elem: $(`
`) + QUnit.test('Test SVGContext', (assert) => { + const parent = { + elem: $(`
`), }; - let ctx = new SVGContext({ + const ctx = new SVGContext({ parent: parent, - name: 'ctx_name' + name: 'ctx_name', }); // SVG Namespace set assert.strictEqual(ctx.svg_ns, svg_ns); @@ -119,18 +112,22 @@ QUnit.module('treibstoff.widget', hooks => { assert.strictEqual(ctx.elem.tagName, 'svg'); // Context elem class matches assert.strictEqual(ctx.elem.getAttribute('class'), 'ctx_name'); - let elem = ctx.svg_elem('g', { - id: 'daphne' - }, ctx.elem); + const elem = ctx.svg_elem( + 'g', + { + id: 'daphne', + }, + ctx.elem, + ); // Create SVG elem from SVGContext assert.strictEqual($(elem).attr('id'), 'daphne'); - ctx.svg_attrs(elem, {id: 'joseph'}); + ctx.svg_attrs(elem, { id: 'joseph' }); // Set SVG attrs from SVGContext assert.strictEqual($(elem).attr('id'), 'joseph'); ctx.reset_state(); }); - QUnit.test('Test Visibility', assert => { + QUnit.test('Test Visibility', (assert) => { let visibility; try { visibility = new Visibility({}); @@ -139,8 +136,8 @@ QUnit.module('treibstoff.widget', hooks => { } assert.verifySteps(['No element given']); - let elem = $('
'); - visibility = new Visibility({elem: elem}); + const elem = $('
'); + visibility = new Visibility({ elem: elem }); visibility.visible = false; assert.ok(elem.hasClass('hidden')); @@ -152,9 +149,9 @@ QUnit.module('treibstoff.widget', hooks => { visibility.hidden = false; assert.false(elem.hasClass('hidden')); - visibility.on('on_visible', (inst, val) => { - assert.step('Visibility: ' + val); - }) + visibility.on('on_visible', (_inst, val) => { + assert.step(`Visibility: ${val}`); + }); visibility.visible = false; assert.verifySteps(['Visibility: false']); @@ -177,33 +174,73 @@ QUnit.module('treibstoff.widget', hooks => { assert.verifySteps([]); }); - QUnit.test('Test Button', assert => { - let elem = $(' - + let z_index = 1055; + z_index += $('.modal:visible').length; + compile_template( + this, + ` + - `); + `, + ); } open() { - $('body') - .css('padding-right', '13px') - .css('overflow-x', 'hidden') - .addClass('modal-open'); - this.container.append(this.elem); + $('body').addClass('modal-open'); + this.container.append(this.wrapper); this.elem.show(); this.is_open = true; this.trigger('on_open'); } close() { if ($('.modal:visible').length === 1) { - $('body') - .css('padding-right', '') - .css('overflow-x', 'auto') - .removeClass('modal-open'); + $('body').removeClass('modal-open'); } - this.elem.remove(); + ajax_destroy(this.wrapper); + this.wrapper.remove(); this.is_open = false; this.trigger('on_close'); } } function get_overlay(uid) { - let elem = $(`#${uid}`); + const elem = $(`#${uid}`); if (!elem.length) { return null; } - let ol = elem.data('overlay'); + const ol = elem.data('overlay'); if (!ol) { return null; } @@ -622,15 +687,18 @@ var ts = (function (exports, $) { class Message extends Overlay { constructor(opts) { opts.content = opts.message ? opts.message : opts.content; - opts.css = opts.flavor ? opts.flavor : opts.css; super(opts); this.compile_actions(); } compile_actions() { - compile_template(this, ` - - `, this.footer); + `, + this.footer, + ); } } function show_message(opts) { @@ -638,30 +706,34 @@ var ts = (function (exports, $) { title: opts.title, message: opts.message, flavor: opts.flavor, - on_open: function(inst) { + css: opts.css, + on_open: (inst) => { $('button', inst.elem).first().focus(); - } + }, }).open(); } - function show_info(message) { + function show_info(message, css) { show_message({ title: 'Info', message: message, - flavor: 'info' + flavor: 'info', + css: css, }); } - function show_warning(message) { + function show_warning(message, css) { show_message({ title: 'Warning', message: message, - flavor: 'warning' + flavor: 'warning', + css: css, }); } - function show_error(message) { + function show_error(message, css) { show_message({ title: 'Error', message: message, - flavor: 'error' + flavor: 'error', + css: css, }); } class Dialog extends Message { @@ -671,12 +743,16 @@ var ts = (function (exports, $) { this.bind_from_options(['on_confirm'], opts); } compile_actions() { - compile_template(this, ` - - - `, this.footer); + `, + this.footer, + ); } on_ok_btn_click() { this.close(); @@ -687,39 +763,47 @@ var ts = (function (exports, $) { new Dialog({ title: opts.title, message: opts.message, - on_confirm: opts.on_confirm + on_confirm: opts.on_confirm, }).open(); } - const default_spinner_image = '/resources/treibstoff/loading-spokes.svg'; class LoadingSpinner { constructor() { this._count = 0; - this.compile(); } compile() { - compile_template(this, ` -
- + compile_template( + this, + ` +
+ Loading...
- `); + `, + ); } show() { this._count++; if (this._count > 1) { return; } + this.compile(); $('body').append(this.elem); } hide(force) { this._count--; if (force) { this._count = 0; - this.elem.remove(); + if (this.elem) { + this.elem.remove(); + } + this.elem = null; return; } else if (this._count <= 0) { this._count = 0; - this.elem.remove(); + if (this.elem) { + this.elem.remove(); + } + this.elem = null; } } } @@ -733,16 +817,16 @@ var ts = (function (exports, $) { } execute(opts) { if (opts.url.indexOf('?') !== -1) { - let params_ = opts.params; + const params_ = opts.params; opts.params = parse_query(opts.url); opts.url = parse_url(opts.url); - for (let key in params_) { + for (const key in params_) { opts.params[key] = params_[key]; } } else { set_default(opts, 'params', {}); } - set_default(opts, 'error', (request, status, error) => { + set_default(opts, 'error', (_request, status, error) => { if (parseInt(status, 10) === 403) { this.redirect(this.default_403); return; @@ -769,7 +853,7 @@ var ts = (function (exports, $) { this.hide_spinner(true); opts.error(request, status, error); }, - cache: set_default(opts, 'cache', false) + cache: set_default(opts, 'cache', false), }); } redirect(path) { @@ -792,7 +876,7 @@ var ts = (function (exports, $) { new HTTPRequest({ spinner: set_default(opts, 'spinner', spinner), win: set_default(opts, 'win', window), - default_403: set_default(opts, 'default_403', '/login') + default_403: set_default(opts, 'default_403', '/login'), }).execute(opts); } @@ -802,7 +886,7 @@ var ts = (function (exports, $) { url: target ? parse_url(target) : undefined, params: target ? parse_query(target) : {}, path: target ? parse_path(target) : undefined, - query: target ? parse_query(target, true) : undefined + query: target ? parse_query(target, true) : undefined, }; } parse_definition(val) { @@ -822,137 +906,14 @@ var ts = (function (exports, $) { this.dispatcher = opts.dispatcher; this.dispatcher.on(this.event, this.handle.bind(this)); } - execute(opts) { + execute(_opts) { throw 'Abstract AjaxOperation does not implement execute'; } - handle(inst, opts) { + handle(_inst, _opts) { throw 'Abstract AjaxOperation does not implement handle'; } } - class AjaxPath extends AjaxOperation { - constructor(opts) { - opts.event = 'on_path'; - super(opts); - this.win = opts.win; - $(this.win).on('popstate', this.state_handle.bind(this)); - } - execute(opts) { - let history = this.win.history; - if (history.pushState === undefined) { - return; - } - let path = opts.path.charAt(0) !== '/' ? `/${opts.path}` : opts.path; - set_default(opts, 'target', this.win.location.origin + path); - set_default(opts, 'replace', false); - let replace = opts.replace; - delete opts.path; - delete opts.replace; - opts._t_ajax = true; - if (replace) { - history.replaceState(opts, '', path); - } else { - history.pushState(opts, '', path); - } - } - state_handle(evt) { - let state = evt.originalEvent.state; - if (!state) { - return; - } - if (!state._t_ajax) { - return; - } - evt.preventDefault(); - let target; - if (state.target.url) { - target = state.target; - } else { - target = this.parse_target(state.target); - } - target.params.popstate = '1'; - if (state.action) { - this.dispatcher.trigger('on_action', { - target: target, - action: state.action - }); - } - if (state.event) { - this.dispatcher.trigger('on_event', { - target: target, - event: state.event - }); - } - if (state.overlay) { - this.dispatcher.trigger('on_overlay', { - target: target, - overlay: state.overlay, - css: state.overlay_css, - uid: state.overlay_uid, - title: state.overlay_title - }); - } - if (!state.action && !state.event && !state.overlay) { - this.win.location = target.url; - } - } - handle(inst, opts) { - let elem = opts.elem, - evt = opts.event, - path = elem.attr('ajax:path'); - if (path === 'href') { - let href = elem.attr('href'); - path = parse_path(href, true); - } else if (path === 'target') { - let tgt = this.action_target(elem, evt); - path = tgt.path + tgt.query; - } - let target; - if (this.has_attr(elem, 'ajax:path-target')) { - let path_target = elem.attr('ajax:path-target'); - if (path_target) { - target = this.parse_target(path_target); - } - } else { - target = this.action_target(elem, evt); - } - let p_opts = { - path: path, - target: target - }; - p_opts.action = this.attr_val(elem, 'ajax:path-action', 'ajax:action'); - p_opts.event = this.attr_val(elem, 'ajax:path-event', 'ajax:event'); - p_opts.overlay = this.attr_val(elem, 'ajax:path-overlay', 'ajax:overlay'); - if (p_opts.overlay) { - p_opts.overlay_css = this.attr_val( - elem, - 'ajax:path-overlay-css', - 'ajax:overlay-css' - ); - p_opts.overlay_uid = this.attr_val( - elem, - 'ajax:path-overlay-uid', - 'ajax:overlay-uid' - ); - p_opts.overlay_title = this.attr_val( - elem, - 'ajax:path-overlay-title', - 'ajax:overlay-title' - ); - } - this.execute(p_opts); - } - has_attr(elem, name) { - let val = elem.attr(name); - return val !== undefined && val !== false; - } - attr_val(elem, name, fallback) { - if (this.has_attr(elem, name)) { - return elem.attr(name); - } else { - return elem.attr(fallback); - } - } - } + class AjaxAction extends AjaxOperation { constructor(opts) { set_default(opts, 'event', 'on_action'); @@ -970,10 +931,10 @@ var ts = (function (exports, $) { opts.params['ajax.mode'] = opts.mode; opts.params['ajax.selector'] = opts.selector; this._request.execute({ - url: parse_url(opts.url) + '/ajaxaction', + url: `${parse_url(opts.url)}/ajaxaction`, type: 'json', params: opts.params, - success: opts.success + success: opts.success, }); } complete(data) { @@ -985,34 +946,91 @@ var ts = (function (exports, $) { this._handle.next(data.continuation); } } - handle(inst, opts) { - let target = opts.target, + handle(_inst, opts) { + const target = opts.target, action = opts.action; - for (let action_ of this.parse_definition(action)) { - let defs = action_.split(':'); + for (const action_ of this.parse_definition(action)) { + const defs = action_.split(':'); this.execute({ name: defs[0], selector: defs[1], mode: defs[2], url: target.url, - params: target.params + params: target.params, + }); + } + } + } + + class AjaxDispatcher extends AjaxUtil { + bind(node, evts) { + $(node).off(evts).on(evts, this.dispatch_handle.bind(this)); + } + dispatch_handle(evt) { + evt.preventDefault(); + evt.stopPropagation(); + const elem = $(evt.currentTarget), + opts = { + elem: elem, + event: evt, + }; + if (elem.attr('ajax:confirm')) { + show_dialog({ + message: elem.attr('ajax:confirm'), + on_confirm: function (_inst) { + this.dispatch(opts); + }.bind(this), + }); + } else { + this.dispatch(opts); + } + } + dispatch(opts) { + const elem = opts.elem, + event = opts.event; + if (elem.attr('ajax:action')) { + this.trigger('on_action', { + target: this.action_target(elem, event), + action: elem.attr('ajax:action'), + }); + } + if (elem.attr('ajax:event')) { + this.trigger('on_event', { + target: elem.attr('ajax:target'), + event: elem.attr('ajax:event'), + }); + } + if (elem.attr('ajax:overlay')) { + this.trigger('on_overlay', { + target: this.action_target(elem, event), + overlay: elem.attr('ajax:overlay'), + css: elem.attr('ajax:overlay-css'), + uid: elem.attr('ajax:overlay-uid'), + title: elem.attr('ajax:overlay-title'), + }); + } + if (elem.attr('ajax:path')) { + this.trigger('on_path', { + elem: elem, + event: event, }); } } } + class AjaxEvent extends AjaxOperation { constructor(opts) { opts.event = 'on_event'; super(opts); } execute(opts) { - let create_event = this.create_event.bind(this); - $(opts.selector).each(function() { + const create_event = this.create_event.bind(this); + $(opts.selector).each(function () { $(this).trigger(create_event(opts.name, opts.target, opts.data)); }); } create_event(name, target, data) { - let evt = $.Event(name); + const evt = $.Event(name); if (target.url) { evt.ajaxtarget = target; } else { @@ -1021,91 +1039,20 @@ var ts = (function (exports, $) { evt.ajaxdata = data; return evt; } - handle(inst, opts) { - let target = opts.target, + handle(_inst, opts) { + const target = opts.target, event = opts.event; - for (let event_ of this.parse_definition(event)) { - let def = event_.split(':'); + for (const event_ of this.parse_definition(event)) { + const def = event_.split(':'); this.execute({ name: def[0], selector: def[1], - target: target - }); - } - } - } - class AjaxOverlay extends AjaxAction { - constructor(opts) { - opts.event = 'on_overlay'; - super(opts); - this.overlay_content_sel = '.modal-body'; - } - execute(opts) { - let ol; - if (opts.close) { - ol = get_overlay(opts.uid); - if (ol) { - ol.close(); - } - return ol; - } - let url, params; - if (opts.target) { - let target = opts.target; - if (!target.url) { - target = this.parse_target(target); - } - url = target.url; - params = target.params; - } else { - url = opts.url; - params = opts.params; - } - let uid = opts.uid ? opts.uid : uuid4(); - params['ajax.overlay-uid'] = uid; - ol = new Overlay({ - uid: uid, - css: opts.css, - title: opts.title, - on_close: opts.on_close - }); - this.request({ - name: opts.action, - selector: `#${uid} ${this.overlay_content_sel}`, - mode: 'inner', - url: url, - params: params, - success: function(data) { - if (!data.payload) { - this.complete(data); - return; - } - ol.open(); - this.complete(data); - }.bind(this) - }); - return ol; - } - handle(inst, opts) { - let target = opts.target, - overlay = opts.overlay; - if (overlay.indexOf('CLOSE') > -1) { - this.execute({ - close: true, - uid: overlay.indexOf(':') > -1 ? overlay.split(':')[1] : opts.uid - }); - return; - } - this.execute({ - action: overlay, - url: target.url, - params: target.params, - css: opts.css, - uid: opts.uid, - title: opts.title - }); + target: target, + }); + } } } + class AjaxForm { constructor(opts) { this.handle = opts.handle; @@ -1114,20 +1061,27 @@ var ts = (function (exports, $) { } bind(form) { if (!this.afr) { - compile_template(this, ` + compile_template( + this, + ` - `, $('body')); + `, + $('body'), + ); } $(form) .append('') .attr('target', 'ajaxformresponse') .off() - .on('submit', function(event) { - this.spinner.show(); - }.bind(this)); + .on( + 'submit', + function (_event) { + this.spinner.show(); + }.bind(this), + ); } render(opts) { this.spinner.hide(); @@ -1141,73 +1095,7 @@ var ts = (function (exports, $) { this.handle.next(opts.next); } } - class AjaxDispatcher extends AjaxUtil { - bind(node, evts) { - $(node).off(evts).on(evts, this.dispatch_handle.bind(this)); - } - dispatch_handle(evt) { - evt.preventDefault(); - evt.stopPropagation(); - let elem = $(evt.currentTarget), - opts = { - elem: elem, - event: evt - }; - if (elem.attr('ajax:confirm')) { - show_dialog({ - message: elem.attr('ajax:confirm'), - on_confirm: function(inst) { - this.dispatch(opts); - }.bind(this) - }); - } else { - this.dispatch(opts); - } - } - dispatch(opts) { - let elem = opts.elem, - event = opts.event; - if (elem.attr('ajax:action')) { - this.trigger('on_action', { - target: this.action_target(elem, event), - action: elem.attr('ajax:action') - }); - } - if (elem.attr('ajax:event')) { - this.trigger('on_event', { - target: elem.attr('ajax:target'), - event: elem.attr('ajax:event') - }); - } - if (elem.attr('ajax:overlay')) { - this.trigger('on_overlay', { - target: this.action_target(elem, event), - overlay: elem.attr('ajax:overlay'), - css: elem.attr('ajax:overlay-css'), - uid: elem.attr('ajax:overlay-uid'), - title: elem.attr('ajax:overlay-title') - }); - } - if (elem.attr('ajax:path')) { - this.trigger('on_path', { - elem: elem, - event: event - }); - } - } - } - class AjaxDestroy extends Parser { - parse(node) { - let instances = node._ajax_attached; - if (instances !== undefined) { - for (let instance of instances) { - if (instance.destroy !== undefined) { - instance.destroy(); - } - } - } - } - } + class AjaxHandle extends AjaxUtil { constructor(ajax) { super(); @@ -1215,8 +1103,8 @@ var ts = (function (exports, $) { this.spinner = ajax.spinner; } destroy(context) { - let parser = new AjaxDestroy(); - context.each(function() { + const parser = new AjaxDestroy(); + context.each(function () { parser.walk(this); }); } @@ -1225,8 +1113,11 @@ var ts = (function (exports, $) { selector = opts.selector, mode = opts.mode, context; + if (payload?.nodeType && payload.ownerDocument !== document) { + payload = document.importNode(payload, true); + } if (mode === 'replace') { - let old_context = $(selector); + const old_context = $(selector); this.destroy(old_context); old_context.replaceWith(payload); context = $(selector); @@ -1247,20 +1138,20 @@ var ts = (function (exports, $) { return; } this.spinner.hide(); - for (let op of operations) { - let type = op.type; + for (const op of operations) { + const type = op.type; delete op.type; if (type === 'path') { this.ajax.path(op); } else if (type === 'action') { - let target = this.parse_target(op.target); + const target = this.parse_target(op.target); op.url = target.url; op.params = target.params; this.ajax.action(op); } else if (type === 'event') { this.ajax.trigger(op); } else if (type === 'overlay') { - let target = this.parse_target(op.target); + const target = this.parse_target(op.target); op.url = target.url; op.params = target.params; this.ajax.overlay(op); @@ -1268,7 +1159,9 @@ var ts = (function (exports, $) { if (op.flavor) { show_message({ message: op.payload, - flavor: op.flavor + flavor: op.flavor, + css: op.css ? op.css : '', + title: op.title ? op.title : '', }); } else { $(op.selector).html(op.payload); @@ -1277,6 +1170,80 @@ var ts = (function (exports, $) { } } } + + class AjaxOverlay extends AjaxAction { + constructor(opts) { + opts.event = 'on_overlay'; + super(opts); + this.overlay_content_sel = '.modal-body'; + } + execute(opts) { + let ol; + if (opts.close) { + ol = get_overlay(opts.uid); + if (ol) { + ol.close(); + } + return ol; + } + let url, params; + if (opts.target) { + let target = opts.target; + if (!target.url) { + target = this.parse_target(target); + } + url = target.url; + params = target.params; + } else { + url = opts.url; + params = opts.params; + } + const uid = opts.uid ? opts.uid : uuid4(); + params['ajax.overlay-uid'] = uid; + ol = new Overlay({ + uid: uid, + css: opts.css, + title: opts.title, + on_close: opts.on_close, + }); + this.request({ + name: opts.action, + selector: `#${uid} ${this.overlay_content_sel}`, + mode: 'inner', + url: url, + params: params, + success: function (data) { + if (!data.payload) { + this.complete(data); + return; + } + ol.open(); + this.complete(data); + }.bind(this), + }); + return ol; + } + handle(_inst, opts) { + const target = opts.target, + overlay = opts.overlay; + if (overlay.indexOf('CLOSE') > -1) { + this.execute({ + close: true, + uid: overlay.indexOf(':') > -1 ? overlay.split(':')[1] : opts.uid, + }); + return; + } + this.execute({ + action: overlay, + url: target.url, + params: target.params, + css: opts.css, + uid: opts.uid, + title: opts.title, + }); + } + } + class AjaxParser extends Parser { constructor(opts) { super(); @@ -1284,12 +1251,12 @@ var ts = (function (exports, $) { this.form = opts.form; } parse(node) { - let attrs = this.node_attrs(node); - if (attrs['ajax:bind'] && ( - attrs['ajax:action'] || - attrs['ajax:event'] || - attrs['ajax:overlay'])) { - let evts = attrs['ajax:bind']; + const attrs = this.node_attrs(node); + if ( + attrs['ajax:bind'] && + (attrs['ajax:action'] || attrs['ajax:event'] || attrs['ajax:overlay']) + ) { + const evts = attrs['ajax:bind']; this.dispatcher.bind(node, evts); } if (attrs['ajax:form']) { @@ -1302,36 +1269,157 @@ var ts = (function (exports, $) { } } } + + class AjaxPath extends AjaxOperation { + constructor(opts) { + opts.event = 'on_path'; + super(opts); + this.win = opts.win; + $(this.win).on('popstate', this.state_handle.bind(this)); + } + execute(opts) { + const history = this.win.history; + if (history.pushState === undefined) { + return; + } + const path = opts.path.charAt(0) !== '/' ? `/${opts.path}` : opts.path; + set_default(opts, 'target', this.win.location.origin + path); + set_default(opts, 'replace', false); + const replace = opts.replace; + delete opts.path; + delete opts.replace; + opts._t_ajax = true; + if (replace) { + history.replaceState(opts, '', path); + } else { + history.pushState(opts, '', path); + } + } + state_handle(evt) { + const state = evt.originalEvent.state; + if (!state) { + return; + } + if (!state._t_ajax) { + return; + } + evt.preventDefault(); + let target; + if (state.target.url) { + target = state.target; + } else { + target = this.parse_target(state.target); + } + target.params.popstate = '1'; + if (state.action) { + this.dispatcher.trigger('on_action', { + target: target, + action: state.action, + }); + } + if (state.event) { + this.dispatcher.trigger('on_event', { + target: target, + event: state.event, + }); + } + if (state.overlay) { + this.dispatcher.trigger('on_overlay', { + target: target, + overlay: state.overlay, + css: state.overlay_css, + uid: state.overlay_uid, + title: state.overlay_title, + }); + } + if (!state.action && !state.event && !state.overlay) { + this.win.location = target.url; + } + } + handle(_inst, opts) { + let elem = opts.elem, + evt = opts.event, + path = elem.attr('ajax:path'); + if (path === 'href') { + const href = elem.attr('href'); + path = parse_path(href, true); + } else if (path === 'target') { + const tgt = this.action_target(elem, evt); + path = tgt.path + tgt.query; + } + let target; + if (this.has_attr(elem, 'ajax:path-target')) { + const path_target = elem.attr('ajax:path-target'); + if (path_target) { + target = this.parse_target(path_target); + } + } else { + target = this.action_target(elem, evt); + } + const p_opts = { + path: path, + target: target, + }; + p_opts.action = this.attr_val(elem, 'ajax:path-action', 'ajax:action'); + p_opts.event = this.attr_val(elem, 'ajax:path-event', 'ajax:event'); + p_opts.overlay = this.attr_val(elem, 'ajax:path-overlay', 'ajax:overlay'); + if (p_opts.overlay) { + p_opts.overlay_css = this.attr_val(elem, 'ajax:path-overlay-css', 'ajax:overlay-css'); + p_opts.overlay_uid = this.attr_val(elem, 'ajax:path-overlay-uid', 'ajax:overlay-uid'); + p_opts.overlay_title = this.attr_val( + elem, + 'ajax:path-overlay-title', + 'ajax:overlay-title', + ); + } + this.execute(p_opts); + } + has_attr(elem, name) { + const val = elem.attr(name); + return val !== undefined && val !== false; + } + attr_val(elem, name, fallback) { + if (this.has_attr(elem, name)) { + return elem.attr(name); + } else { + return elem.attr(fallback); + } + } + } + class Ajax extends AjaxUtil { - constructor(win=window) { + constructor(win = window) { super(); this.win = win; this.binders = {}; - let spinner_ = this.spinner = spinner; - let dispatcher = this.dispatcher = new AjaxDispatcher(); - let request = this._request = new HTTPRequest({win: win}); - this._path = new AjaxPath({dispatcher: dispatcher, win: win}); - this._event = new AjaxEvent({dispatcher: dispatcher}); - let handle = new AjaxHandle(this); - let action_opts = { + this.spinner = spinner; + this.dispatcher = new AjaxDispatcher(); + this._request = new HTTPRequest({ win: win }); + const spinner_ = this.spinner; + const dispatcher = this.dispatcher; + const request = this._request; + this._path = new AjaxPath({ dispatcher: dispatcher, win: win }); + this._event = new AjaxEvent({ dispatcher: dispatcher }); + const handle = new AjaxHandle(this); + const action_opts = { dispatcher: dispatcher, win: win, handle: handle, spinner: spinner_, - request: request + request: request, }; this._action = new AjaxAction(action_opts); this._overlay = new AjaxOverlay(action_opts); - this._form = new AjaxForm({handle: handle, spinner: spinner_}); + this._form = new AjaxForm({ handle: handle, spinner: spinner_ }); this._is_bound = false; } register(func, instant) { - let func_name = 'binder_' + uuid4(); + let func_name = `binder_${uuid4()}`; while (true) { if (this.binders[func_name] === undefined) { break; } - func_name = 'binder_' + uuid4(); + func_name = `binder_${uuid4()}`; } this.binders[func_name] = func; if (instant && this._is_bound) { @@ -1340,14 +1428,14 @@ var ts = (function (exports, $) { } bind(context) { this._is_bound = true; - let parser = new AjaxParser({ + const parser = new AjaxParser({ dispatcher: this.dispatcher, - form: this._form + form: this._form, }); - context.each(function() { + context.each(function () { parser.walk(this); }); - for (let func_name in this.binders) { + for (const func_name in this.binders) { try { this.binders[func_name](context); } catch (err) { @@ -1358,8 +1446,8 @@ var ts = (function (exports, $) { } attach(instance, elem) { if (elem instanceof $) { - if (elem.length != 1) { - throw 'Instance can be attached to exactly one DOM element'; + if (elem.length !== 1) { + throw `${instance.constructor.name}: Instance can be attached to exactly one DOM element`; } elem = elem[0]; } @@ -1374,15 +1462,18 @@ var ts = (function (exports, $) { action(opts) { this._action.execute(opts); } - trigger(opts) { - if (arguments.length > 1) { + trigger(...args) { + let opts; + if (args.length > 1) { deprecate('Calling Ajax.event with positional arguments', 'opts', '1.0'); opts = { - name: arguments[0], - selector: arguments[1], - target: arguments[2], - data: arguments[3] + name: args[0], + selector: args[1], + target: args[2], + data: args[3], }; + } else { + opts = args[0]; } this._event.execute(opts); } @@ -1408,9 +1499,9 @@ var ts = (function (exports, $) { deprecate('ts.ajax.parsetarget', 'ts.ajax.parse_target', '1.0'); return this.parse_target(target); } - message(message, flavor='') { + message(message, flavor = '') { deprecate('ts.ajax.message', 'ts.show_message', '1.0'); - show_message({message: message, flavor: flavor}); + show_message({ message: message, flavor: flavor }); } info(message) { deprecate('ts.ajax.info', 'ts.show_info', '1.0'); @@ -1428,9 +1519,9 @@ var ts = (function (exports, $) { deprecate('ts.ajax.dialog', 'ts.show_dialog', '1.0'); show_dialog({ message: opts.message, - on_confirm: function() { + on_confirm: () => { callback(opts); - } + }, }); } request(opts) { @@ -1438,12 +1529,30 @@ var ts = (function (exports, $) { http_request(opts); } } - let ajax = new Ajax(); - $.fn.tsajax = function() { + const ajax = new Ajax(); + $.fn.tsajax = function () { ajax.bind(this); return this; }; + function destroy_bootstrap(node) { + let dd = window.bootstrap.Dropdown.getInstance(node); + let tt = window.bootstrap.Tooltip.getInstance(node); + if (dd) { + dd.dispose(); + } + if (tt) { + tt.dispose(); + } + dd = null; + tt = null; + } + $(() => { + if (window.bootstrap !== undefined) { + register_ajax_destroy_handle(destroy_bootstrap); + } + }); + class ClockFrameEvent { constructor(callback, ...opts) { this._request_id = window.requestAnimationFrame((timestamp) => { @@ -1494,9 +1603,79 @@ var ts = (function (exports, $) { return new ClockIntervalEvent(callback, interval, ...opts); } } - let clock = new Clock(); + const clock = new Clock(); + + class DnD extends Events { + static _drag_source = null; + constructor() { + super(); + this._drag_scope = null; + this._drop_scope = null; + this._dragstart_handle = null; + this._dragend_handle = null; + this._dragover_handle = null; + this._dragleave_handle = null; + this._drop_handle = null; + } + set_scope(drag, drop) { + this.reset_scope(); + this._drag_scope = drag; + this._drop_scope = drop; + if (drag) { + drag.attr('draggable', 'true'); + this._dragstart_handle = this._dragstart.bind(this); + this._dragend_handle = this._dragend.bind(this); + drag.on('dragstart', this._dragstart_handle); + drag.on('dragend', this._dragend_handle); + } + if (drop) { + this._dragover_handle = this._dragover.bind(this); + this._dragleave_handle = this._dragleave.bind(this); + this._drop_handle = this._drop.bind(this); + drop.on('dragover', this._dragover_handle); + drop.on('dragleave', this._dragleave_handle); + drop.on('drop', this._drop_handle); + } + } + reset_scope() { + if (this._drag_scope) { + this._drag_scope.off('dragstart', this._dragstart_handle); + this._drag_scope.off('dragend', this._dragend_handle); + this._drag_scope.removeAttr('draggable'); + } + if (this._drop_scope) { + this._drop_scope.off('dragover', this._dragover_handle); + this._drop_scope.off('dragleave', this._dragleave_handle); + this._drop_scope.off('drop', this._drop_handle); + } + this._drag_scope = null; + this._drop_scope = null; + } + _dragstart(evt) { + evt.originalEvent.dataTransfer.setData('text/plain', ''); + DnD._drag_source = this; + this.trigger('dragstart', evt); + } + _dragover(evt) { + evt.originalEvent.preventDefault(); + evt.source = DnD._drag_source; + this.trigger('dragover', evt); + } + _dragleave(evt) { + this.trigger('dragleave', evt); + } + _drop(evt) { + evt.originalEvent.preventDefault(); + evt.source = DnD._drag_source; + this.trigger('drop', evt); + } + _dragend(evt) { + DnD._drag_source = null; + this.trigger('dragend', evt); + } + } - function create_listener(event, base=null) { + function create_listener(event, base = null) { base = base || Events; if (!(base === Events || base.prototype instanceof Events)) { throw 'Base class must be subclass of or Events'; @@ -1515,16 +1694,23 @@ var ts = (function (exports, $) { if (!elem) { throw 'No element found'; } - elem.on(event, evt => { - this.trigger(`on_${event}`, evt); - }); + this.event = event; + this.trigger_event = this.trigger_event.bind(this); + this.elem.on(this.event, this.trigger_event); + ajax.attach(this, this.elem); + } + trigger_event(evt) { + this.trigger(`on_${event}`, evt); + } + destroy() { + this.elem.off(this.event, this.trigger_event); } }; } - let ClickListener = create_listener('click'); - let clickListener = Base => create_listener('click', Base); - let ChangeListener = create_listener('change'); - let changeListener = Base => create_listener('change', Base); + const ClickListener = create_listener('click'); + const clickListener = (Base) => create_listener('click', Base); + const ChangeListener = create_listener('change'); + const changeListener = (Base) => create_listener('change', Base); class Motion extends Events { constructor() { @@ -1557,7 +1743,7 @@ var ts = (function (exports, $) { this._motion = false; this._prev_pos = { x: evt.pageX, - y: evt.pageY + y: evt.pageY, }; if (this._move_scope) { this._move_handle = this._mousemove.bind(this); @@ -1595,19 +1781,17 @@ var ts = (function (exports, $) { new Property(this, 'parent'); this.parent = opts.parent || null; } - add_widget(widget){ + add_widget(widget) { widget.parent = this; this.children.push(widget); } - remove_widget(widget){ + remove_widget(widget) { widget.parent = null; - this.children.splice( - this.children.indexOf(widget), 1 - ); + this.children.splice(this.children.indexOf(widget), 1); } acquire(cls) { let parent = this.parent; - while(parent) { + while (parent) { if (!parent || parent instanceof cls) { break; } @@ -1620,8 +1804,8 @@ var ts = (function (exports, $) { constructor(opts) { super(opts); this.elem = opts.elem; - new CSSProperty(this, 'x', {tgt: 'left'}); - new CSSProperty(this, 'y', {tgt: 'top'}); + new CSSProperty(this, 'x', { tgt: 'left' }); + new CSSProperty(this, 'y', { tgt: 'top' }); new CSSProperty(this, 'width'); new CSSProperty(this, 'height'); } @@ -1631,14 +1815,14 @@ var ts = (function (exports, $) { } class SVGContext extends HTMLWidget { constructor(opts) { - let container = opts.parent.elem.get(0); - opts.elem = create_svg_elem('svg', {'class': opts.name}, container); + const container = opts.parent.elem.get(0); + opts.elem = create_svg_elem('svg', { class: opts.name }, container); super(opts); this.svg_ns = svg_ns; this.xyz = { x: 0, y: 0, - z: 1 + z: 1, }; } svg_attrs(el, opts) { @@ -1660,7 +1844,7 @@ var ts = (function (exports, $) { return !this.elem.hasClass('hidden'); } set visible(value) { - let trigger = value !== !this.elem.hasClass('hidden'); + const trigger = value !== !this.elem.hasClass('hidden'); set_visible(this.elem, value); if (trigger) { this.trigger('on_visible', value); @@ -1681,7 +1865,7 @@ var ts = (function (exports, $) { this.elem = opts.elem; } get collapsed() { - return !this.elem.hasClass('in'); + return !this.elem.hasClass('show'); } set collapsed(value) { if (value) { @@ -1694,21 +1878,17 @@ var ts = (function (exports, $) { class Button extends ClickListener { constructor(opts) { super(opts); - this.unselected_class = 'btn-default'; - this.selected_class = 'btn-success'; + this.unselected_class = opts.unselected_class ?? 'btn-primary'; + this.selected_class = opts.selected_class ?? 'btn-success'; } get selected() { return this.elem.hasClass(this.selected_class); } set selected(value) { if (value) { - this.elem - .removeClass(this.unselected_class) - .addClass(this.selected_class); + this.elem.removeClass(this.unselected_class).addClass(this.selected_class); } else { - this.elem - .removeClass(this.selected_class) - .addClass(this.unselected_class); + this.elem.removeClass(this.selected_class).addClass(this.unselected_class); } } } @@ -1717,7 +1897,7 @@ var ts = (function (exports, $) { if (opts.elem) { return opts.elem; } - let form = opts.form, + const form = opts.form, name = opts.name, elem = get_elem(`${prefix}-${form.name}-${name}`, form.elem, true); return elem; @@ -1752,8 +1932,8 @@ var ts = (function (exports, $) { } set options(value) { this.clear(); - let selection = this.elem[0]; - for (let option of value) { + const selection = this.elem[0]; + for (const option of value) { if (!(option instanceof Option)) { selection.add(new Option(option[1], option[0])); } else { @@ -1775,9 +1955,9 @@ var ts = (function (exports, $) { type: 'json', url: this.vocab, params: params, - success: function(data, status, request) { + success: function (data, _status, _request) { this.options = data; - }.bind(this) + }.bind(this), }); } } @@ -1790,7 +1970,7 @@ var ts = (function (exports, $) { return this.elem.is(':checked'); } set checked(value) { - return this.elem.prop('checked', value); + this.elem.prop('checked', value); } } class FormField extends Visibility { @@ -1803,7 +1983,7 @@ var ts = (function (exports, $) { if (input && !(input instanceof FormInput)) { input = new input({ form: this.form, - name: this.name + name: this.name, }); } this.input = input; @@ -1812,14 +1992,14 @@ var ts = (function (exports, $) { return this.elem.hasClass('has-error'); } set has_error(value) { - let elem = this.elem; + const elem = this.elem; if (value) { elem.addClass('has-error'); } else { elem.removeClass('has-error'); } } - reset(value='') { + reset(value = '') { this.input.value = value; this.has_error = false; $('.help-block', this.elem).remove(); @@ -1827,13 +2007,13 @@ var ts = (function (exports, $) { } class Form { static initialize(context, factory, name) { - let elem = query_elem(`#form-${name}`, context, true); + const elem = query_elem(`#form-${name}`, context, true); if (!elem) { return; } - let form = new factory({ + const form = new factory({ name: name, - elem: elem + elem: elem, }); elem.data(name, form); } @@ -1845,7 +2025,7 @@ var ts = (function (exports, $) { this.elem = opts.elem; } set_field_visibility(fields, visible) { - for (let field of fields) { + for (const field of fields) { field.visible = visible; } } @@ -1865,45 +2045,41 @@ var ts = (function (exports, $) { this.bind(); } unload() { - $(window) - .off('keydown', this._on_dom_keydown) - .off('keyup', this._on_dom_keyup); + $(window).off('keydown', this._on_dom_keydown).off('keyup', this._on_dom_keyup); } bind() { this._on_dom_keydown = this._on_dom_keydown.bind(this); this._on_dom_keyup = this._on_dom_keyup.bind(this); - $(window) - .on('keydown', this._on_dom_keydown) - .on('keyup', this._on_dom_keyup); + $(window).on('keydown', this._on_dom_keydown).on('keyup', this._on_dom_keyup); } _add_key(name, key_code) { this._keys.push(name); this[`_${name}`] = false; Object.defineProperty(this, name, { - get: function() { + get: function () { return this[`_${name}`]; }, - set: function(evt) { - let val = this[`_${name}`]; - if (evt.type == 'keydown') { - if (!val && evt.keyCode == key_code) { + set: function (evt) { + const val = this[`_${name}`]; + if (evt.type === 'keydown') { + if (!val && evt.keyCode === key_code) { this[`_${name}`] = true; } } else { - if (val && evt.keyCode == key_code) { + if (val && evt.keyCode === key_code) { this[`_${name}`] = false; } } - } + }, }); } _set_keys(evt) { - for (let name of this._keys) { + for (const name of this._keys) { this[name] = evt; } } _filter_event(evt) { - return this.filter_keyevent && this.filter_keyevent(evt); + return this.filter_keyevent?.(evt); } _on_dom_keydown(evt) { this._set_keys(evt); @@ -1924,7 +2100,7 @@ var ts = (function (exports, $) { const WS_STATE_CLOSING = 2; const WS_STATE_CLOSED = 3; class Websocket extends Events { - constructor(path, factory=WebSocket) { + constructor(path, factory = WebSocket) { super(); this._ws_factory = factory; this.path = path; @@ -1937,7 +2113,7 @@ var ts = (function (exports, $) { } get uri() { let scheme; - if (window.location.protocol == 'http:') { + if (window.location.protocol === 'http:') { scheme = 'ws://'; } else { scheme = 'wss://'; @@ -1951,17 +2127,18 @@ var ts = (function (exports, $) { if (this.sock !== null) { this.sock.close(); } - let sock = this.sock = new this._ws_factory(this.uri); - sock.onopen = function() { + this.sock = new this._ws_factory(this.uri); + const sock = this.sock; + sock.onopen = function () { this.trigger('on_open'); }.bind(this); - sock.onclose = function(evt) { + sock.onclose = function (evt) { this.trigger('on_close', evt); }.bind(this); - sock.onerror = function() { + sock.onerror = function () { this.trigger('on_error'); }.bind(this); - sock.onmessage = function(evt) { + sock.onmessage = function (evt) { this.trigger('on_raw_message', evt); }.bind(this); } @@ -1977,16 +2154,12 @@ var ts = (function (exports, $) { this.sock = null; } } - on_open() { - } - on_close(evt) { - } - on_error() { - } - on_message(data) { - } + on_open() {} + on_close(_evt) {} + on_error() {} + on_message(_data) {} on_raw_message(evt) { - let data = JSON.parse(evt.data); + const data = JSON.parse(evt.data); if (data.HEARTBEAT !== undefined) { return; } @@ -1994,7 +2167,7 @@ var ts = (function (exports, $) { } } - $(function() { + $(() => { ajax.spinner.hide(); $(document).tsajax(); }); @@ -2025,6 +2198,7 @@ var ts = (function (exports, $) { exports.Collapsible = Collapsible; exports.DataProperty = DataProperty; exports.Dialog = Dialog; + exports.DnD = DnD; exports.Events = Events; exports.Form = Form; exports.FormCheckbox = FormCheckbox; @@ -2056,6 +2230,7 @@ var ts = (function (exports, $) { exports.Websocket = Websocket; exports.Widget = Widget; exports.ajax = ajax; + exports.ajax_destroy = ajax_destroy; exports.changeListener = changeListener; exports.clickListener = clickListener; exports.clock = clock; @@ -2079,6 +2254,7 @@ var ts = (function (exports, $) { exports.parse_url = parse_url; exports.query_elem = query_elem; exports.read_cookie = read_cookie; + exports.register_ajax_destroy_handle = register_ajax_destroy_handle; exports.set_default = set_default; exports.set_svg_attrs = set_svg_attrs; exports.set_visible = set_visible; @@ -2089,6 +2265,7 @@ var ts = (function (exports, $) { exports.show_warning = show_warning; exports.spinner = spinner; exports.svg_ns = svg_ns; + exports.unregister_ajax_destroy_handle = unregister_ajax_destroy_handle; exports.uuid4 = uuid4; Object.defineProperty(exports, '__esModule', { value: true }); diff --git a/treibstoff/bundle/treibstoff.bundle.min.js b/treibstoff/bundle/treibstoff.bundle.min.js index d31fbc5..08db40e 100644 --- a/treibstoff/bundle/treibstoff.bundle.min.js +++ b/treibstoff/bundle/treibstoff.bundle.min.js @@ -1 +1 @@ -var ts=function(t,e){"use strict";function s(t,e,s){console.log(`DEPRECATED: ${t} is deprecated and will be removed as of ${s}. Use ${e} instead.`)}function i(t,s,i=!0){let n=e(t,s);if(i&&n.length>1)throw`Element by selector ${t} not unique.`;return n.length?n:null}function n(t,e,s=!0){let n=i(t,e,s);if(null===n)throw`Element by selector ${t} not found.`;return n}function r(t,e){e?t.removeClass("hidden"):t.addClass("hidden")}function a(){return([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,(t=>(t^crypto.getRandomValues(new Uint8Array(1))[0]&15>>t/4).toString(16)))}function o(t,e,s){return void 0===t[e]&&(t[e]=s),t[e]}function l(t,e){return t.charAt(t.length-1)===e&&(t=t.substring(0,t.length-1)),t}function h(t){let e=document.createElement("a");e.href=t;let s=e.pathname;return l(t=e.protocol+"//"+e.host+s,"/")}function c(t,e){let s=document.createElement("a");s.href=t;let i=s.search;if(e)return i||"";let n={};if(i){let t=i.substring(1,i.length).split("&");for(let e=0;e${e[1]}`);this.handle_input(t,e)}handle_button(t,e){let s=e["t-prop"];if(!s)return;let i=this.widget;new w(i,s,{ctx:t,ctxa:e["t-elem"],val:e["t-val"]});for(let t of["down","up","click"])if(e[`t-bind-${t}`]){let n=i[e[`t-bind-${t}`]].bind(i);this.widget.on(`on_${s}_${t}`,n)}}}function C(t,s,i){let n=e(s.trim());i&&i.append(n);let r=new k(t);return n.each((function(){r.walk(this)})),n}class $ extends y{}class A{constructor(){this._subscribers={},this._suppress_events=!1}on(t,e){let s=this._subscribers[t];return void 0===s&&(this._subscribers[t]=s=new Array),this._contains_subscriber(t,e)||s.push(e),this}off(t,e){let s=this._subscribers[t];if(void 0===s)return this;if(!e)return delete this._subscribers[t],this;let i=s.indexOf(e);return i>-1&&(s=s.splice(i,1)),this._subscribers[t]=s,this}trigger(t,...e){if(this._suppress_events)return;this[t]&&this[t](...e);let s=this._subscribers[t];if(!s)return this;for(let t=0;t\n \n
\n `)}open(){e("body").css("padding-right","13px").css("overflow-x","hidden").addClass("modal-open"),this.container.append(this.elem),this.elem.show(),this.is_open=!0,this.trigger("on_open")}close(){1===e(".modal:visible").length&&e("body").css("padding-right","").css("overflow-x","auto").removeClass("modal-open"),this.elem.remove(),this.is_open=!1,this.trigger("on_close")}}function S(t){let s=e(`#${t}`);if(!s.length)return null;let i=s.data("overlay");return i||null}class T extends E{constructor(t){t.content=t.message?t.message:t.content,t.css=t.flavor?t.flavor:t.css,super(t),this.compile_actions()}compile_actions(){C(this,'\n \n ',this.footer)}}function O(t){new T({title:t.title,message:t.message,flavor:t.flavor,on_open:function(t){e("button",t.elem).first().focus()}}).open()}function q(t){O({title:"Info",message:t,flavor:"info"})}function N(t){O({title:"Warning",message:t,flavor:"warning"})}function P(t){O({title:"Error",message:t,flavor:"error"})}class L extends T{constructor(t){o(t,"css","dialog"),super(t),this.bind_from_options(["on_confirm"],t)}compile_actions(){C(this,'\n \n \n ',this.footer)}on_ok_btn_click(){this.close(),this.trigger("on_confirm")}}function D(t){new L({title:t.title,message:t.message,on_confirm:t.on_confirm}).open()}class M{constructor(){this._count=0,this.compile()}compile(){C(this,'\n
\n \n
\n ')}show(){this._count++,this._count>1||e("body").append(this.elem)}hide(t){if(this._count--,t)return this._count=0,void this.elem.remove();this._count<=0&&(this._count=0,this.elem.remove())}}const F=new M;class I{constructor(t){this.spinner=o(t,"spinner",null),this.default_403=o(t,"default_403","/login"),this._win=o(t,"win",window)}execute(t){if(-1!==t.url.indexOf("?")){let e=t.params;t.params=c(t.url),t.url=h(t.url);for(let s in e)t.params[s]=e[s]}else o(t,"params",{});o(t,"error",((t,e,s)=>{403!==parseInt(e,10)?P(`${e}${s}`):this.redirect(this.default_403)})),this.show_spinner(),e.ajax({url:t.url,dataType:o(t,"type","html"),data:t.params,method:o(t,"method","GET"),success:(e,s,i)=>{this.hide_spinner(),t.success(e,s,i)},error:(e,s,i)=>{0!==e.status?(s=e.status||s,i=e.statusText||i,this.hide_spinner(!0),t.error(e,s,i)):this.hide_spinner(!0)},cache:o(t,"cache",!1)})}redirect(t){const e=this._win.location;e.hash="",e.pathname=t}show_spinner(){null!==this.spinner&&this.spinner.show()}hide_spinner(t){null!==this.spinner&&this.spinner.hide(t)}}function W(t){new I({spinner:o(t,"spinner",F),win:o(t,"win",window),default_403:o(t,"default_403","/login")}).execute(t)}class G extends A{parse_target(t){return{url:t?h(t):void 0,params:t?c(t):{},path:t?d(t):void 0,query:t?c(t,!0):void 0}}parse_definition(t){return t.replace(/\s+/g," ").split(" ")}action_target(t,e){return e.ajaxtarget?e.ajaxtarget:this.parse_target(t.attr("ajax:target"))}}class H extends G{constructor(t){super(),this.event=t.event,this.dispatcher=t.dispatcher,this.dispatcher.on(this.event,this.handle.bind(this))}execute(t){throw"Abstract AjaxOperation does not implement execute"}handle(t,e){throw"Abstract AjaxOperation does not implement handle"}}class R extends H{constructor(t){t.event="on_path",super(t),this.win=t.win,e(this.win).on("popstate",this.state_handle.bind(this))}execute(t){let e=this.win.history;if(void 0===e.pushState)return;let s="/"!==t.path.charAt(0)?`/${t.path}`:t.path;o(t,"target",this.win.location.origin+s),o(t,"replace",!1);let i=t.replace;delete t.path,delete t.replace,t._t_ajax=!0,i?e.replaceState(t,"",s):e.pushState(t,"",s)}state_handle(t){let e,s=t.originalEvent.state;s&&s._t_ajax&&(t.preventDefault(),e=s.target.url?s.target:this.parse_target(s.target),e.params.popstate="1",s.action&&this.dispatcher.trigger("on_action",{target:e,action:s.action}),s.event&&this.dispatcher.trigger("on_event",{target:e,event:s.event}),s.overlay&&this.dispatcher.trigger("on_overlay",{target:e,overlay:s.overlay,css:s.overlay_css,uid:s.overlay_uid,title:s.overlay_title}),s.action||s.event||s.overlay||(this.win.location=e.url))}handle(t,e){let s,i=e.elem,n=e.event,r=i.attr("ajax:path");if("href"===r){r=d(i.attr("href"),!0)}else if("target"===r){let t=this.action_target(i,n);r=t.path+t.query}if(this.has_attr(i,"ajax:path-target")){let t=i.attr("ajax:path-target");t&&(s=this.parse_target(t))}else s=this.action_target(i,n);let a={path:r,target:s};a.action=this.attr_val(i,"ajax:path-action","ajax:action"),a.event=this.attr_val(i,"ajax:path-event","ajax:event"),a.overlay=this.attr_val(i,"ajax:path-overlay","ajax:overlay"),a.overlay&&(a.overlay_css=this.attr_val(i,"ajax:path-overlay-css","ajax:overlay-css"),a.overlay_uid=this.attr_val(i,"ajax:path-overlay-uid","ajax:overlay-uid"),a.overlay_title=this.attr_val(i,"ajax:path-overlay-title","ajax:overlay-title")),this.execute(a)}has_attr(t,e){let s=t.attr(e);return void 0!==s&&!1!==s}attr_val(t,e,s){return this.has_attr(t,e)?t.attr(e):t.attr(s)}}class V extends H{constructor(t){o(t,"event","on_action"),super(t),this.spinner=t.spinner,this._handle=t.handle,this._request=t.request}execute(t){t.success=this.complete.bind(this),this.request(t)}request(t){t.params["ajax.action"]=t.name,t.params["ajax.mode"]=t.mode,t.params["ajax.selector"]=t.selector,this._request.execute({url:h(t.url)+"/ajaxaction",type:"json",params:t.params,success:t.success})}complete(t){t?(this._handle.update(t),this._handle.next(t.continuation)):(P("Empty Response"),this.spinner.hide())}handle(t,e){let s=e.target,i=e.action;for(let t of this.parse_definition(i)){let e=t.split(":");this.execute({name:e[0],selector:e[1],mode:e[2],url:s.url,params:s.params})}}}class B extends H{constructor(t){t.event="on_event",super(t)}execute(t){let s=this.create_event.bind(this);e(t.selector).each((function(){e(this).trigger(s(t.name,t.target,t.data))}))}create_event(t,s,i){let n=e.Event(t);return s.url?n.ajaxtarget=s:n.ajaxtarget=this.parse_target(s),n.ajaxdata=i,n}handle(t,e){let s=e.target,i=e.event;for(let t of this.parse_definition(i)){let e=t.split(":");this.execute({name:e[0],selector:e[1],target:s})}}}class z extends V{constructor(t){t.event="on_overlay",super(t),this.overlay_content_sel=".modal-body"}execute(t){let e,s,i;if(t.close)return e=S(t.uid),e&&e.close(),e;if(t.target){let e=t.target;e.url||(e=this.parse_target(e)),s=e.url,i=e.params}else s=t.url,i=t.params;let n=t.uid?t.uid:a();return i["ajax.overlay-uid"]=n,e=new E({uid:n,css:t.css,title:t.title,on_close:t.on_close}),this.request({name:t.action,selector:`#${n} ${this.overlay_content_sel}`,mode:"inner",url:s,params:i,success:function(t){t.payload?(e.open(),this.complete(t)):this.complete(t)}.bind(this)}),e}handle(t,e){let s=e.target,i=e.overlay;i.indexOf("CLOSE")>-1?this.execute({close:!0,uid:i.indexOf(":")>-1?i.split(":")[1]:e.uid}):this.execute({action:i,url:s.url,params:s.params,css:e.css,uid:e.uid,title:e.title})}}class J{constructor(t){this.handle=t.handle,this.spinner=t.spinner,this.afr=null}bind(t){this.afr||C(this,'\n \n ',e("body")),e(t).append('').attr("target","ajaxformresponse").off().on("submit",function(t){this.spinner.show()}.bind(this))}render(t){this.spinner.hide(),t.error||(this.afr.remove(),this.afr=null),t.payload&&this.handle.update(t),this.handle.next(t.next)}}class U extends G{bind(t,s){e(t).off(s).on(s,this.dispatch_handle.bind(this))}dispatch_handle(t){t.preventDefault(),t.stopPropagation();let s=e(t.currentTarget),i={elem:s,event:t};s.attr("ajax:confirm")?D({message:s.attr("ajax:confirm"),on_confirm:function(t){this.dispatch(i)}.bind(this)}):this.dispatch(i)}dispatch(t){let e=t.elem,s=t.event;e.attr("ajax:action")&&this.trigger("on_action",{target:this.action_target(e,s),action:e.attr("ajax:action")}),e.attr("ajax:event")&&this.trigger("on_event",{target:e.attr("ajax:target"),event:e.attr("ajax:event")}),e.attr("ajax:overlay")&&this.trigger("on_overlay",{target:this.action_target(e,s),overlay:e.attr("ajax:overlay"),css:e.attr("ajax:overlay-css"),uid:e.attr("ajax:overlay-uid"),title:e.attr("ajax:overlay-title")}),e.attr("ajax:path")&&this.trigger("on_path",{elem:e,event:s})}}class K extends b{parse(t){let e=t._ajax_attached;if(void 0!==e)for(let t of e)void 0!==t.destroy&&t.destroy()}}class X extends G{constructor(t){super(),this.ajax=t,this.spinner=t.spinner}destroy(t){let e=new K;t.each((function(){e.walk(this)}))}update(t){let s,i=t.payload,n=t.selector,r=t.mode;if("replace"===r){let t=e(n);this.destroy(t),t.replaceWith(i),s=e(n),s.length?this.ajax.bind(s.parent()):this.ajax.bind(e(document))}else"inner"===r&&(s=e(n),this.destroy(s.children()),s.html(i),this.ajax.bind(s))}next(t){if(t&&t.length){this.spinner.hide();for(let s of t){let t=s.type;if(delete s.type,"path"===t)this.ajax.path(s);else if("action"===t){let t=this.parse_target(s.target);s.url=t.url,s.params=t.params,this.ajax.action(s)}else if("event"===t)this.ajax.trigger(s);else if("overlay"===t){let t=this.parse_target(s.target);s.url=t.url,s.params=t.params,this.ajax.overlay(s)}else"message"===t&&(s.flavor?O({message:s.payload,flavor:s.flavor}):e(s.selector).html(s.payload))}}}}class Y extends b{constructor(t){super(),this.dispatcher=t.dispatcher,this.form=t.form}parse(t){let e=this.node_attrs(t);if(e["ajax:bind"]&&(e["ajax:action"]||e["ajax:event"]||e["ajax:overlay"])){let s=e["ajax:bind"];this.dispatcher.bind(t,s)}e["ajax:form"]&&this.form.bind(t),"form"===t.tagName.toLowerCase()&&t.className.split(" ").includes("ajax")&&this.form.bind(t)}}class Q extends G{constructor(t=window){super(),this.win=t,this.binders={};let e=this.spinner=F,s=this.dispatcher=new U,i=this._request=new I({win:t});this._path=new R({dispatcher:s,win:t}),this._event=new B({dispatcher:s});let n=new X(this),r={dispatcher:s,win:t,handle:n,spinner:e,request:i};this._action=new V(r),this._overlay=new z(r),this._form=new J({handle:n,spinner:e}),this._is_bound=!1}register(t,e){let s="binder_"+a();for(;void 0!==this.binders[s];)s="binder_"+a();this.binders[s]=t,e&&this._is_bound&&t()}bind(t){this._is_bound=!0;let e=new Y({dispatcher:this.dispatcher,form:this._form});t.each((function(){e.walk(this)}));for(let e in this.binders)try{this.binders[e](t)}catch(t){console.log(t)}return t}attach(t,s){if(s instanceof e){if(1!=s.length)throw"Instance can be attached to exactly one DOM element";s=s[0]}void 0===s._ajax_attached&&(s._ajax_attached=[]),s._ajax_attached.push(t)}path(t){this._path.execute(t)}action(t){this._action.execute(t)}trigger(t){arguments.length>1&&(s("Calling Ajax.event with positional arguments","opts","1.0"),t={name:arguments[0],selector:arguments[1],target:arguments[2],data:arguments[3]}),this._event.execute(t)}overlay(t){return this._overlay.execute(t)}form(t){this._form.render(t)}parseurl(t){return s("ts.ajax.parseurl","ts.parse_url","1.0"),h(t)}parsequery(t,e){return s("ts.ajax.parsequery","ts.parse_query","1.0"),c(t,e)}parsepath(t,e){return s("ts.ajax.parsepath","ts.parse_path","1.0"),d(t,e)}parsetarget(t){return s("ts.ajax.parsetarget","ts.ajax.parse_target","1.0"),this.parse_target(t)}message(t,e=""){s("ts.ajax.message","ts.show_message","1.0"),O({message:t,flavor:e})}info(t){s("ts.ajax.info","ts.show_info","1.0"),q(t)}warning(t){s("ts.ajax.warning","ts.show_warning","1.0"),N(t)}error(t){s("ts.ajax.error","ts.show_error","1.0"),P(t)}dialog(t,e){s("ts.ajax.dialog","ts.show_dialog","1.0"),D({message:t.message,on_confirm:function(){e(t)}})}request(t){s("ts.ajax.request","ts.http_request","1.0"),W(t)}}let Z=new Q;e.fn.tsajax=function(){return Z.bind(this),this};class tt{constructor(t,...e){this._request_id=window.requestAnimationFrame((s=>{t(s,...e)}))}cancel(){null!==this._request_id&&(window.cancelAnimationFrame(this._request_id),this._request_id=null)}}class et{constructor(t,e,...s){this._timeout_id=window.setTimeout((()=>{t(document.timeline.currentTime,...s)}),e)}cancel(){null!==this._timeout_id&&(window.clearTimeout(this._timeout_id),this._timeout_id=null)}}class st{constructor(t,e,...s){this._interval_id=window.setInterval((()=>{t(document.timeline.currentTime,this,...s)}),e)}cancel(){null!==this._interval_id&&(window.clearInterval(this._interval_id),this._interval_id=null)}}class it{schedule_frame(t,...e){return new tt(t,...e)}schedule_timeout(t,e,...s){return new et(t,e,...s)}schedule_interval(t,e,...s){return new st(t,e,...s)}}let nt=new it;function rt(t,e=null){if(!((e=e||A)===A||e.prototype instanceof A))throw"Base class must be subclass of or Events";return class extends e{constructor(s){e===A?super():super(s);let i=this.elem;if(i||void 0===s||(i=this.elem=s.elem),!i)throw"No element found";i.on(t,(e=>{this.trigger(`on_${t}`,e)}))}}}let at=rt("click"),ot=rt("change"),lt=t=>rt("change",t);class ht extends A{constructor(){super(),this._down_handle=null,this._down_scope=null,this._move_scope=null}reset_state(){this._move_handle=null,this._up_handle=null,this._prev_pos=null,this._motion=null}set_scope(t,s){if(this._up_handle)throw"Attempt to set motion scope while handling";this.reset_state(),this._down_handle&&e(this._down_scope).off("mousedown",this._down_handle),this._down_handle=this._mousedown.bind(this),this._down_scope=t,e(t).on("mousedown",this._down_handle),this._move_scope=s||null}_mousedown(t){t.stopPropagation(),this._motion=!1,this._prev_pos={x:t.pageX,y:t.pageY},this._move_scope&&(this._move_handle=this._mousemove.bind(this),e(this._move_scope).on("mousemove",this._move_handle)),this._up_handle=this._mouseup.bind(this),e(document).on("mouseup",this._up_handle),this.trigger("down",t)}_mousemove(t){t.stopPropagation(),this._motion=!0,t.motion=this._motion,t.prev_pos=this._prev_pos,this.trigger("move",t),this._prev_pos.x=t.pageX,this._prev_pos.y=t.pageY}_mouseup(t){t.stopPropagation(),this._move_scope&&e(this._move_scope).off("mousemove",this._move_handle),e(document).off("mouseup",this._up_handle),t.motion=this._motion,this.trigger("up",t),this.reset_state()}}class ct extends ht{constructor(t){super(),this.children=[],new g(this,"parent"),this.parent=t.parent||null}add_widget(t){t.parent=this,this.children.push(t)}remove_widget(t){t.parent=null,this.children.splice(this.children.indexOf(t),1)}acquire(t){let e=this.parent;for(;e&&e&&!(e instanceof t);)e=e.parent;return e}}class dt extends ct{constructor(t){super(t),this.elem=t.elem,new v(this,"x",{tgt:"left"}),new v(this,"y",{tgt:"top"}),new v(this,"width"),new v(this,"height")}get offset(){return e(this.elem).offset()}}class ut extends A{constructor(t){if(!t.elem)throw"No element given";super(),this.elem=t.elem}get visible(){return!this.elem.hasClass("hidden")}set visible(t){let e=t!==!this.elem.hasClass("hidden");r(this.elem,t),e&&this.trigger("on_visible",t)}get hidden(){return!this.visible}set hidden(t){this.visible=!t}}function _t(t,e){if(t.elem)return t.elem;let s=t.form,i=t.name;return n(`${e}-${s.name}-${i}`,s.elem,!0)}class pt extends A{constructor(t){super(),this.form=t.form,this.name=t.name,this.elem=_t(t,"#input")}get value(){return this.elem.val()}set value(t){this.elem.val(t)}get disabled(){return this.elem.prop("disabled")}set disabled(t){this.elem.prop("disabled",t)}}class mt extends(lt(pt)){constructor(t){t.elem=_t(t,"#input"),super(t)}get options(){return this.elem.prop("options")}set options(t){this.clear();let e=this.elem[0];for(let s of t)s instanceof Option?e.add(s):e.add(new Option(s[1],s[0]))}clear(){this.elem.empty()}}class gt extends(lt(pt)){constructor(t){t.elem=_t(t,"#input"),super(t)}get checked(){return this.elem.is(":checked")}set checked(t){return this.elem.prop("checked",t)}}return e((function(){Z.spinner.hide(),e(document).tsajax()})),t.Ajax=Q,t.AjaxAction=V,t.AjaxDestroy=K,t.AjaxDispatcher=U,t.AjaxEvent=B,t.AjaxForm=J,t.AjaxHandle=X,t.AjaxOperation=H,t.AjaxOverlay=z,t.AjaxParser=Y,t.AjaxPath=R,t.AjaxUtil=G,t.AttrProperty=class extends f{set(t){this.ctx.attr(this.tgt,t),super.set(t)}},t.BoundProperty=f,t.Button=class extends at{constructor(t){super(t),this.unselected_class="btn-default",this.selected_class="btn-success"}get selected(){return this.elem.hasClass(this.selected_class)}set selected(t){t?this.elem.removeClass(this.unselected_class).addClass(this.selected_class):this.elem.removeClass(this.selected_class).addClass(this.unselected_class)}},t.ButtonProperty=w,t.CSSProperty=v,t.ChangeListener=ot,t.ClickListener=at,t.Clock=it,t.ClockFrameEvent=tt,t.ClockIntervalEvent=st,t.ClockTimeoutEvent=et,t.Collapsible=class{constructor(t){if(!t.elem)throw"No element given";this.elem=t.elem}get collapsed(){return!this.elem.hasClass("in")}set collapsed(t){t?this.elem.collapse("hide"):this.elem.collapse("show")}},t.DataProperty=class extends f{constructor(t,e,s){s?s.ctx=void 0!==s.ctx?s.ctx:t.data:s={ctx:t.data},s.ctxa="data",super(t,e,s)}set(t){this.ctx[this.tgt]=t,super.set(t)}},t.Dialog=L,t.Events=A,t.Form=class{static initialize(t,e,s){let n=i(`#form-${s}`,t,!0);if(!n)return;let r=new e({name:s,elem:n});n.data(s,r)}static instance(t){return e(`#form-${t}`).data(t)}constructor(t){this.name=t.name,this.elem=t.elem}set_field_visibility(t,e){for(let s of t)s.visible=e}},t.FormCheckbox=gt,t.FormField=class extends ut{constructor(t){t.elem=_t(t,"#field"),super(t),this.form=t.form,this.name=t.name;let e=t.input;!e||e instanceof pt||(e=new e({form:this.form,name:this.name})),this.input=e}get has_error(){return this.elem.hasClass("has-error")}set has_error(t){let e=this.elem;t?e.addClass("has-error"):e.removeClass("has-error")}reset(t=""){this.input.value=t,this.has_error=!1,e(".help-block",this.elem).remove()}},t.FormInput=pt,t.FormRemoteSelect=class extends mt{constructor(t){super(t),this.vocab=t.vocab}fetch(t){W({type:"json",url:this.vocab,params:t,success:function(t,e,s){this.options=t}.bind(this)})}},t.FormSelect=mt,t.HTMLParser=k,t.HTMLWidget=dt,t.HTTPRequest=I,t.InputProperty=x,t.KeyState=class extends A{constructor(t){super(),this.filter_keyevent=t,this._keys=[],this._add_key("ctrl",17),this._add_key("shift",16),this._add_key("alt",18),this._add_key("enter",13),this._add_key("esc",27),this._add_key("delete",46),this.bind()}unload(){e(window).off("keydown",this._on_dom_keydown).off("keyup",this._on_dom_keyup)}bind(){this._on_dom_keydown=this._on_dom_keydown.bind(this),this._on_dom_keyup=this._on_dom_keyup.bind(this),e(window).on("keydown",this._on_dom_keydown).on("keyup",this._on_dom_keyup)}_add_key(t,e){this._keys.push(t),this[`_${t}`]=!1,Object.defineProperty(this,t,{get:function(){return this[`_${t}`]},set:function(s){let i=this[`_${t}`];"keydown"==s.type?i||s.keyCode!=e||(this[`_${t}`]=!0):i&&s.keyCode==e&&(this[`_${t}`]=!1)}})}_set_keys(t){for(let e of this._keys)this[e]=t}_filter_event(t){return this.filter_keyevent&&this.filter_keyevent(t)}_on_dom_keydown(t){this._set_keys(t),this._filter_event(t)||this.trigger("keydown",t)}_on_dom_keyup(t){this._set_keys(t),this._filter_event(t)||this.trigger("keyup",t)}},t.LoadingSpinner=M,t.Message=T,t.Motion=ht,t.Overlay=E,t.Parser=b,t.Property=g,t.SVGContext=class extends dt{constructor(t){let e=t.parent.elem.get(0);t.elem=p("svg",{class:t.name},e),super(t),this.svg_ns=u,this.xyz={x:0,y:0,z:1}}svg_attrs(t,e){_(t,e)}svg_elem(t,e,s){return p(t,e,s)}},t.SVGParser=$,t.SVGProperty=class extends f{set(t){let e={};e[this._name]=t,_(this.ctx,e),super.set(t)}},t.TemplateParser=y,t.TextProperty=class extends f{set(t){this.ctx.text(t),super.set(t)}},t.Visibility=ut,t.WS_STATE_CLOSED=3,t.WS_STATE_CLOSING=2,t.WS_STATE_CONNECTING=0,t.WS_STATE_OPEN=1,t.Websocket=class extends A{constructor(t,e=WebSocket){super(),this._ws_factory=e,this.path=t,this.on_open=this.on_open.bind(this),this.on_close=this.on_close.bind(this),this.on_error=this.on_error.bind(this),this.on_message=this.on_message.bind(this),this.on_raw_message=this.on_raw_message.bind(this),this.sock=null}get uri(){let t;return t="http:"==window.location.protocol?"ws://":"wss://",t+window.location.hostname+this.path}get state(){return this.sock.readyState}open(){null!==this.sock&&this.sock.close();let t=this.sock=new this._ws_factory(this.uri);t.onopen=function(){this.trigger("on_open")}.bind(this),t.onclose=function(t){this.trigger("on_close",t)}.bind(this),t.onerror=function(){this.trigger("on_error")}.bind(this),t.onmessage=function(t){this.trigger("on_raw_message",t)}.bind(this)}send(t){this.sock.send(t)}send_json(t){this.sock.send(JSON.stringify(t))}close(){null!==this.sock&&(this.sock.close(),this.sock=null)}on_open(){}on_close(t){}on_error(){}on_message(t){}on_raw_message(t){let e=JSON.parse(t.data);void 0===e.HEARTBEAT&&this.trigger("on_message",e)}},t.Widget=ct,t.ajax=Z,t.changeListener=lt,t.clickListener=t=>rt("click",t),t.clock=nt,t.compile_svg=function(t,e,s){let i=m(e,s),n=new $(t);return i.forEach((function(t,e){n.walk(t)})),i},t.compile_template=C,t.create_cookie=function(t,e,s){var i,n;s?((i=new Date).setTime(i.getTime()+24*s*60*60*1e3),n="; expires="+i.toGMTString()):n="",document.cookie=t+"="+escape(e)+n+"; path=/;"},t.create_listener=rt,t.create_svg_elem=p,t.deprecate=s,t.extract_number=j,t.get_elem=n,t.get_overlay=S,t.http_request=W,t.json_merge=function(t,e){let s={};for(let i of[t,e])for(let t in i)s[t]=i[t];return s},t.load_svg=function(t,s){e.get(t,function(t){let i=e(t).find("svg");i.removeAttr("xmlns:a"),s(i)}.bind(this),"xml")},t.lookup_form_elem=_t,t.object_by_path=function(t){if(!t)return null;let e=window;for(const s of t.split("."))if(e=e[s],void 0===e)throw`Object by path not exists: ${t}`;return e},t.parse_path=d,t.parse_query=c,t.parse_svg=m,t.parse_url=h,t.query_elem=i,t.read_cookie=function(t){var e,s,i=t+"=",n=document.cookie.split(";");for(e=0;e-1&&s.splice(n,1),this._subscribers[t]=s,this}trigger(t,...e){if(this._suppress_events)return;this[t]&&this[t](...e);const s=this._subscribers[t];if(!s)return this;for(let t=0;t1)throw`Element by selector ${t} not unique.`;return i.length?i:null}function r(t,e,s=!0){const n=i(t,e,s);if(null===n)throw`Element by selector ${t} not found.`;return n}function a(t,e){e?t.removeClass("hidden"):t.addClass("hidden")}function o(){return([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,(t=>(t^crypto.getRandomValues(new Uint8Array(1))[0]&15>>t/4).toString(16)))}function h(t,e,s){return void 0===t[e]&&(t[e]=s),t[e]}function l(t,e){return t.charAt(t.length-1)===e&&(t=t.substring(0,t.length-1)),t}function c(t){const e=document.createElement("a");e.href=t;const s=e.pathname;return l(t=`${e.protocol}//${e.host}${s}`,"/")}function d(t,e){const s=document.createElement("a");s.href=t;const n=s.search;if(e)return n||"";const i={};if(n){const t=n.substring(1,n.length).split("&");for(let e=0;e=0?t.setAttributeNS(null,s,e[s]):console.error(`Invalid value for ${s}:`,e[s],t)}else t.setAttributeNS(null,s,e[s])}function m(t,e,s){const n=document.createElementNS(u,t);return p(n,e),void 0!==s&&s.appendChild(n),n}function g(t,e){const s=m("svg",{});s.innerHTML=t.trim();const n=[],i=s.childNodes;for(let t=0;t${e[1]}`);this.handle_input(t,e)}handle_button(t,e){const s=e["t-prop"];if(!s)return;const n=this.widget;new b(n,s,{ctx:t,ctxa:e["t-elem"],val:e["t-val"]});for(const t of["down","up","click"])if(e[`t-bind-${t}`]){const i=n[e[`t-bind-${t}`]].bind(n);this.widget.on(`on_${s}_${t}`,i)}}}function C(t,s,n){const i=e(s.trim());n&&n.append(i);const r=new $(t);return i.each((function(){r.walk(this)})),i}class A extends j{}var E=[];class T extends y{parse(t){const s=t._ajax_attached;if(void 0!==s){for(const t of s)void 0!==t.destroy?t.destroy():console.warn(`ts.ajax bound but no destroy method defined: ${t.constructor.name}`);t._ajax_attached=null}for(const e of E)e(t);e(t).off().removeData().empty()}}function S(t){t=t instanceof e?t.get(0):t;let s=new T;s.walk(t),s=null}function N(t){E.includes(t)?console.warn(`Warning: Ajax destroy handle already registered, skipping registration: ${t}`):E.push(t)}class O extends s{constructor(t){super(),this.uid=t.uid?t.uid:o(),this.flavor=t.flavor?t.flavor:"",this.css=t.css?t.css:"",this.title=t.title?t.title:" ",this.content=t.content?t.content:"",this.bind_from_options(["on_open","on_close"],t),this.container=t.container?t.container:e("body"),this.compile(),this.elem.data("overlay",this),this.is_open=!1}compile(){let t=1055;t+=e(".modal:visible").length,C(this,`\n \n `)}open(){e("body").addClass("modal-open"),this.container.append(this.wrapper),this.elem.show(),this.is_open=!0,this.trigger("on_open")}close(){1===e(".modal:visible").length&&e("body").removeClass("modal-open"),S(this.wrapper),this.wrapper.remove(),this.is_open=!1,this.trigger("on_close")}}function q(t){const s=e(`#${t}`);if(!s.length)return null;const n=s.data("overlay");return n||null}class P extends O{constructor(t){t.content=t.message?t.message:t.content,super(t),this.compile_actions()}compile_actions(){C(this,'\n \n ',this.footer)}}function D(t){new P({title:t.title,message:t.message,flavor:t.flavor,css:t.css,on_open:t=>{e("button",t.elem).first().focus()}}).open()}function I(t,e){D({title:"Info",message:t,flavor:"info",css:e})}function L(t,e){D({title:"Warning",message:t,flavor:"warning",css:e})}function F(t,e){D({title:"Error",message:t,flavor:"error",css:e})}class M extends P{constructor(t){h(t,"css","dialog"),super(t),this.bind_from_options(["on_confirm"],t)}compile_actions(){C(this,'\n \n \n ',this.footer)}on_ok_btn_click(){this.close(),this.trigger("on_confirm")}}function W(t){new M({title:t.title,message:t.message,on_confirm:t.on_confirm}).open()}class G{constructor(){this._count=0}compile(){C(this,'\n
\n Loading...\n
\n ')}show(){this._count++,this._count>1||(this.compile(),e("body").append(this.elem))}hide(t){if(this._count--,t)return this._count=0,this.elem&&this.elem.remove(),void(this.elem=null);this._count<=0&&(this._count=0,this.elem&&this.elem.remove(),this.elem=null)}}const R=new G;class H{constructor(t){this.spinner=h(t,"spinner",null),this.default_403=h(t,"default_403","/login"),this._win=h(t,"win",window)}execute(t){if(-1!==t.url.indexOf("?")){const e=t.params;t.params=d(t.url),t.url=c(t.url);for(const s in e)t.params[s]=e[s]}else h(t,"params",{});h(t,"error",((t,e,s)=>{403!==parseInt(e,10)?F(`${e}${s}`):this.redirect(this.default_403)})),this.show_spinner(),e.ajax({url:t.url,dataType:h(t,"type","html"),data:t.params,method:h(t,"method","GET"),success:(e,s,n)=>{this.hide_spinner(),t.success(e,s,n)},error:(e,s,n)=>{0!==e.status?(s=e.status||s,n=e.statusText||n,this.hide_spinner(!0),t.error(e,s,n)):this.hide_spinner(!0)},cache:h(t,"cache",!1)})}redirect(t){const e=this._win.location;e.hash="",e.pathname=t}show_spinner(){null!==this.spinner&&this.spinner.show()}hide_spinner(t){null!==this.spinner&&this.spinner.hide(t)}}function V(t){new H({spinner:h(t,"spinner",R),win:h(t,"win",window),default_403:h(t,"default_403","/login")}).execute(t)}class B extends s{parse_target(t){return{url:t?c(t):void 0,params:t?d(t):{},path:t?_(t):void 0,query:t?d(t,!0):void 0}}parse_definition(t){return t.replace(/\s+/g," ").split(" ")}action_target(t,e){return e.ajaxtarget?e.ajaxtarget:this.parse_target(t.attr("ajax:target"))}}class z extends B{constructor(t){super(),this.event=t.event,this.dispatcher=t.dispatcher,this.dispatcher.on(this.event,this.handle.bind(this))}execute(t){throw"Abstract AjaxOperation does not implement execute"}handle(t,e){throw"Abstract AjaxOperation does not implement handle"}}class U extends z{constructor(t){h(t,"event","on_action"),super(t),this.spinner=t.spinner,this._handle=t.handle,this._request=t.request}execute(t){t.success=this.complete.bind(this),this.request(t)}request(t){t.params["ajax.action"]=t.name,t.params["ajax.mode"]=t.mode,t.params["ajax.selector"]=t.selector,this._request.execute({url:`${c(t.url)}/ajaxaction`,type:"json",params:t.params,success:t.success})}complete(t){t?(this._handle.update(t),this._handle.next(t.continuation)):(F("Empty Response"),this.spinner.hide())}handle(t,e){const s=e.target,n=e.action;for(const t of this.parse_definition(n)){const e=t.split(":");this.execute({name:e[0],selector:e[1],mode:e[2],url:s.url,params:s.params})}}}class J extends B{bind(t,s){e(t).off(s).on(s,this.dispatch_handle.bind(this))}dispatch_handle(t){t.preventDefault(),t.stopPropagation();const s=e(t.currentTarget),n={elem:s,event:t};s.attr("ajax:confirm")?W({message:s.attr("ajax:confirm"),on_confirm:function(t){this.dispatch(n)}.bind(this)}):this.dispatch(n)}dispatch(t){const e=t.elem,s=t.event;e.attr("ajax:action")&&this.trigger("on_action",{target:this.action_target(e,s),action:e.attr("ajax:action")}),e.attr("ajax:event")&&this.trigger("on_event",{target:e.attr("ajax:target"),event:e.attr("ajax:event")}),e.attr("ajax:overlay")&&this.trigger("on_overlay",{target:this.action_target(e,s),overlay:e.attr("ajax:overlay"),css:e.attr("ajax:overlay-css"),uid:e.attr("ajax:overlay-uid"),title:e.attr("ajax:overlay-title")}),e.attr("ajax:path")&&this.trigger("on_path",{elem:e,event:s})}}class K extends z{constructor(t){t.event="on_event",super(t)}execute(t){const s=this.create_event.bind(this);e(t.selector).each((function(){e(this).trigger(s(t.name,t.target,t.data))}))}create_event(t,s,n){const i=e.Event(t);return s.url?i.ajaxtarget=s:i.ajaxtarget=this.parse_target(s),i.ajaxdata=n,i}handle(t,e){const s=e.target,n=e.event;for(const t of this.parse_definition(n)){const e=t.split(":");this.execute({name:e[0],selector:e[1],target:s})}}}class X{constructor(t){this.handle=t.handle,this.spinner=t.spinner,this.afr=null}bind(t){this.afr||C(this,'\n \n ',e("body")),e(t).append('').attr("target","ajaxformresponse").off().on("submit",function(t){this.spinner.show()}.bind(this))}render(t){this.spinner.hide(),t.error||(this.afr.remove(),this.afr=null),t.payload&&this.handle.update(t),this.handle.next(t.next)}}class Y extends B{constructor(t){super(),this.ajax=t,this.spinner=t.spinner}destroy(t){const e=new T;t.each((function(){e.walk(this)}))}update(t){let s,n=t.payload,i=t.selector,r=t.mode;if(n?.nodeType&&n.ownerDocument!==document&&(n=document.importNode(n,!0)),"replace"===r){const t=e(i);this.destroy(t),t.replaceWith(n),s=e(i),s.length?this.ajax.bind(s.parent()):this.ajax.bind(e(document))}else"inner"===r&&(s=e(i),this.destroy(s.children()),s.html(n),this.ajax.bind(s))}next(t){if(t&&t.length){this.spinner.hide();for(const s of t){const t=s.type;if(delete s.type,"path"===t)this.ajax.path(s);else if("action"===t){const t=this.parse_target(s.target);s.url=t.url,s.params=t.params,this.ajax.action(s)}else if("event"===t)this.ajax.trigger(s);else if("overlay"===t){const t=this.parse_target(s.target);s.url=t.url,s.params=t.params,this.ajax.overlay(s)}else"message"===t&&(s.flavor?D({message:s.payload,flavor:s.flavor,css:s.css?s.css:"",title:s.title?s.title:""}):e(s.selector).html(s.payload))}}}}class Q extends U{constructor(t){t.event="on_overlay",super(t),this.overlay_content_sel=".modal-body"}execute(t){let e,s,n;if(t.close)return e=q(t.uid),e&&e.close(),e;if(t.target){let e=t.target;e.url||(e=this.parse_target(e)),s=e.url,n=e.params}else s=t.url,n=t.params;const i=t.uid?t.uid:o();return n["ajax.overlay-uid"]=i,e=new O({uid:i,css:t.css,title:t.title,on_close:t.on_close}),this.request({name:t.action,selector:`#${i} ${this.overlay_content_sel}`,mode:"inner",url:s,params:n,success:function(t){t.payload?(e.open(),this.complete(t)):this.complete(t)}.bind(this)}),e}handle(t,e){const s=e.target,n=e.overlay;n.indexOf("CLOSE")>-1?this.execute({close:!0,uid:n.indexOf(":")>-1?n.split(":")[1]:e.uid}):this.execute({action:n,url:s.url,params:s.params,css:e.css,uid:e.uid,title:e.title})}}class Z extends y{constructor(t){super(),this.dispatcher=t.dispatcher,this.form=t.form}parse(t){const e=this.node_attrs(t);if(e["ajax:bind"]&&(e["ajax:action"]||e["ajax:event"]||e["ajax:overlay"])){const s=e["ajax:bind"];this.dispatcher.bind(t,s)}e["ajax:form"]&&this.form.bind(t),"form"===t.tagName.toLowerCase()&&t.className.split(" ").includes("ajax")&&this.form.bind(t)}}class tt extends z{constructor(t){t.event="on_path",super(t),this.win=t.win,e(this.win).on("popstate",this.state_handle.bind(this))}execute(t){const e=this.win.history;if(void 0===e.pushState)return;const s="/"!==t.path.charAt(0)?`/${t.path}`:t.path;h(t,"target",this.win.location.origin+s),h(t,"replace",!1);const n=t.replace;delete t.path,delete t.replace,t._t_ajax=!0,n?e.replaceState(t,"",s):e.pushState(t,"",s)}state_handle(t){const e=t.originalEvent.state;if(!e)return;if(!e._t_ajax)return;let s;t.preventDefault(),s=e.target.url?e.target:this.parse_target(e.target),s.params.popstate="1",e.action&&this.dispatcher.trigger("on_action",{target:s,action:e.action}),e.event&&this.dispatcher.trigger("on_event",{target:s,event:e.event}),e.overlay&&this.dispatcher.trigger("on_overlay",{target:s,overlay:e.overlay,css:e.overlay_css,uid:e.overlay_uid,title:e.overlay_title}),e.action||e.event||e.overlay||(this.win.location=s.url)}handle(t,e){let s,n=e.elem,i=e.event,r=n.attr("ajax:path");if("href"===r){r=_(n.attr("href"),!0)}else if("target"===r){const t=this.action_target(n,i);r=t.path+t.query}if(this.has_attr(n,"ajax:path-target")){const t=n.attr("ajax:path-target");t&&(s=this.parse_target(t))}else s=this.action_target(n,i);const a={path:r,target:s};a.action=this.attr_val(n,"ajax:path-action","ajax:action"),a.event=this.attr_val(n,"ajax:path-event","ajax:event"),a.overlay=this.attr_val(n,"ajax:path-overlay","ajax:overlay"),a.overlay&&(a.overlay_css=this.attr_val(n,"ajax:path-overlay-css","ajax:overlay-css"),a.overlay_uid=this.attr_val(n,"ajax:path-overlay-uid","ajax:overlay-uid"),a.overlay_title=this.attr_val(n,"ajax:path-overlay-title","ajax:overlay-title")),this.execute(a)}has_attr(t,e){const s=t.attr(e);return void 0!==s&&!1!==s}attr_val(t,e,s){return this.has_attr(t,e)?t.attr(e):t.attr(s)}}class et extends B{constructor(t=window){super(),this.win=t,this.binders={},this.spinner=R,this.dispatcher=new J,this._request=new H({win:t});const e=this.spinner,s=this.dispatcher,n=this._request;this._path=new tt({dispatcher:s,win:t}),this._event=new K({dispatcher:s});const i=new Y(this),r={dispatcher:s,win:t,handle:i,spinner:e,request:n};this._action=new U(r),this._overlay=new Q(r),this._form=new X({handle:i,spinner:e}),this._is_bound=!1}register(t,e){let s=`binder_${o()}`;for(;void 0!==this.binders[s];)s=`binder_${o()}`;this.binders[s]=t,e&&this._is_bound&&t()}bind(t){this._is_bound=!0;const e=new Z({dispatcher:this.dispatcher,form:this._form});t.each((function(){e.walk(this)}));for(const e in this.binders)try{this.binders[e](t)}catch(t){console.log(t)}return t}attach(t,s){if(s instanceof e){if(1!==s.length)throw`${t.constructor.name}: Instance can be attached to exactly one DOM element`;s=s[0]}void 0===s._ajax_attached&&(s._ajax_attached=[]),s._ajax_attached.push(t)}path(t){this._path.execute(t)}action(t){this._action.execute(t)}trigger(...t){let e;t.length>1?(n("Calling Ajax.event with positional arguments","opts","1.0"),e={name:t[0],selector:t[1],target:t[2],data:t[3]}):e=t[0],this._event.execute(e)}overlay(t){return this._overlay.execute(t)}form(t){this._form.render(t)}parseurl(t){return n("ts.ajax.parseurl","ts.parse_url","1.0"),c(t)}parsequery(t,e){return n("ts.ajax.parsequery","ts.parse_query","1.0"),d(t,e)}parsepath(t,e){return n("ts.ajax.parsepath","ts.parse_path","1.0"),_(t,e)}parsetarget(t){return n("ts.ajax.parsetarget","ts.ajax.parse_target","1.0"),this.parse_target(t)}message(t,e=""){n("ts.ajax.message","ts.show_message","1.0"),D({message:t,flavor:e})}info(t){n("ts.ajax.info","ts.show_info","1.0"),I(t)}warning(t){n("ts.ajax.warning","ts.show_warning","1.0"),L(t)}error(t){n("ts.ajax.error","ts.show_error","1.0"),F(t)}dialog(t,e){n("ts.ajax.dialog","ts.show_dialog","1.0"),W({message:t.message,on_confirm:()=>{e(t)}})}request(t){n("ts.ajax.request","ts.http_request","1.0"),V(t)}}const st=new et;function nt(t){let e=window.bootstrap.Dropdown.getInstance(t),s=window.bootstrap.Tooltip.getInstance(t);e&&e.dispose(),s&&s.dispose(),e=null,s=null}e.fn.tsajax=function(){return st.bind(this),this},e((()=>{void 0!==window.bootstrap&&N(nt)}));class it{constructor(t,...e){this._request_id=window.requestAnimationFrame((s=>{t(s,...e)}))}cancel(){null!==this._request_id&&(window.cancelAnimationFrame(this._request_id),this._request_id=null)}}class rt{constructor(t,e,...s){this._timeout_id=window.setTimeout((()=>{t(document.timeline.currentTime,...s)}),e)}cancel(){null!==this._timeout_id&&(window.clearTimeout(this._timeout_id),this._timeout_id=null)}}class at{constructor(t,e,...s){this._interval_id=window.setInterval((()=>{t(document.timeline.currentTime,this,...s)}),e)}cancel(){null!==this._interval_id&&(window.clearInterval(this._interval_id),this._interval_id=null)}}class ot{schedule_frame(t,...e){return new it(t,...e)}schedule_timeout(t,e,...s){return new rt(t,e,...s)}schedule_interval(t,e,...s){return new at(t,e,...s)}}const ht=new ot;class lt extends s{static _drag_source=null;constructor(){super(),this._drag_scope=null,this._drop_scope=null,this._dragstart_handle=null,this._dragend_handle=null,this._dragover_handle=null,this._dragleave_handle=null,this._drop_handle=null}set_scope(t,e){this.reset_scope(),this._drag_scope=t,this._drop_scope=e,t&&(t.attr("draggable","true"),this._dragstart_handle=this._dragstart.bind(this),this._dragend_handle=this._dragend.bind(this),t.on("dragstart",this._dragstart_handle),t.on("dragend",this._dragend_handle)),e&&(this._dragover_handle=this._dragover.bind(this),this._dragleave_handle=this._dragleave.bind(this),this._drop_handle=this._drop.bind(this),e.on("dragover",this._dragover_handle),e.on("dragleave",this._dragleave_handle),e.on("drop",this._drop_handle))}reset_scope(){this._drag_scope&&(this._drag_scope.off("dragstart",this._dragstart_handle),this._drag_scope.off("dragend",this._dragend_handle),this._drag_scope.removeAttr("draggable")),this._drop_scope&&(this._drop_scope.off("dragover",this._dragover_handle),this._drop_scope.off("dragleave",this._dragleave_handle),this._drop_scope.off("drop",this._drop_handle)),this._drag_scope=null,this._drop_scope=null}_dragstart(t){t.originalEvent.dataTransfer.setData("text/plain",""),lt._drag_source=this,this.trigger("dragstart",t)}_dragover(t){t.originalEvent.preventDefault(),t.source=lt._drag_source,this.trigger("dragover",t)}_dragleave(t){this.trigger("dragleave",t)}_drop(t){t.originalEvent.preventDefault(),t.source=lt._drag_source,this.trigger("drop",t)}_dragend(t){lt._drag_source=null,this.trigger("dragend",t)}}function ct(t,e=null){if(!((e=e||s)===s||e.prototype instanceof s))throw"Base class must be subclass of or Events";return class extends e{constructor(n){e===s?super():super(n);let i=this.elem;if(i||void 0===n||(i=this.elem=n.elem),!i)throw"No element found";this.event=t,this.trigger_event=this.trigger_event.bind(this),this.elem.on(this.event,this.trigger_event),st.attach(this,this.elem)}trigger_event(e){this.trigger(`on_${t}`,e)}destroy(){this.elem.off(this.event,this.trigger_event)}}}const dt=ct("click"),_t=ct("change"),ut=t=>ct("change",t);class pt extends s{constructor(){super(),this._down_handle=null,this._down_scope=null,this._move_scope=null}reset_state(){this._move_handle=null,this._up_handle=null,this._prev_pos=null,this._motion=null}set_scope(t,s){if(this._up_handle)throw"Attempt to set motion scope while handling";this.reset_state(),this._down_handle&&e(this._down_scope).off("mousedown",this._down_handle),this._down_handle=this._mousedown.bind(this),this._down_scope=t,e(t).on("mousedown",this._down_handle),this._move_scope=s||null}_mousedown(t){t.stopPropagation(),this._motion=!1,this._prev_pos={x:t.pageX,y:t.pageY},this._move_scope&&(this._move_handle=this._mousemove.bind(this),e(this._move_scope).on("mousemove",this._move_handle)),this._up_handle=this._mouseup.bind(this),e(document).on("mouseup",this._up_handle),this.trigger("down",t)}_mousemove(t){t.stopPropagation(),this._motion=!0,t.motion=this._motion,t.prev_pos=this._prev_pos,this.trigger("move",t),this._prev_pos.x=t.pageX,this._prev_pos.y=t.pageY}_mouseup(t){t.stopPropagation(),this._move_scope&&e(this._move_scope).off("mousemove",this._move_handle),e(document).off("mouseup",this._up_handle),t.motion=this._motion,this.trigger("up",t),this.reset_state()}}class mt extends pt{constructor(t){super(),this.children=[],new f(this,"parent"),this.parent=t.parent||null}add_widget(t){t.parent=this,this.children.push(t)}remove_widget(t){t.parent=null,this.children.splice(this.children.indexOf(t),1)}acquire(t){let e=this.parent;for(;e&&e&&!(e instanceof t);)e=e.parent;return e}}class gt extends mt{constructor(t){super(t),this.elem=t.elem,new x(this,"x",{tgt:"left"}),new x(this,"y",{tgt:"top"}),new x(this,"width"),new x(this,"height")}get offset(){return e(this.elem).offset()}}class ft extends s{constructor(t){if(!t.elem)throw"No element given";super(),this.elem=t.elem}get visible(){return!this.elem.hasClass("hidden")}set visible(t){const e=t!==!this.elem.hasClass("hidden");a(this.elem,t),e&&this.trigger("on_visible",t)}get hidden(){return!this.visible}set hidden(t){this.visible=!t}}function vt(t,e){if(t.elem)return t.elem;const s=t.form,n=t.name;return r(`${e}-${s.name}-${n}`,s.elem,!0)}class xt extends s{constructor(t){super(),this.form=t.form,this.name=t.name,this.elem=vt(t,"#input")}get value(){return this.elem.val()}set value(t){this.elem.val(t)}get disabled(){return this.elem.prop("disabled")}set disabled(t){this.elem.prop("disabled",t)}}class wt extends(ut(xt)){constructor(t){t.elem=vt(t,"#input"),super(t)}get options(){return this.elem.prop("options")}set options(t){this.clear();const e=this.elem[0];for(const s of t)s instanceof Option?e.add(s):e.add(new Option(s[1],s[0]))}clear(){this.elem.empty()}}class bt extends(ut(xt)){constructor(t){t.elem=vt(t,"#input"),super(t)}get checked(){return this.elem.is(":checked")}set checked(t){this.elem.prop("checked",t)}}return e((()=>{st.spinner.hide(),e(document).tsajax()})),t.Ajax=et,t.AjaxAction=U,t.AjaxDestroy=T,t.AjaxDispatcher=J,t.AjaxEvent=K,t.AjaxForm=X,t.AjaxHandle=Y,t.AjaxOperation=z,t.AjaxOverlay=Q,t.AjaxParser=Z,t.AjaxPath=tt,t.AjaxUtil=B,t.AttrProperty=class extends v{set(t){this.ctx.attr(this.tgt,t),super.set(t)}},t.BoundProperty=v,t.Button=class extends dt{constructor(t){super(t),this.unselected_class=t.unselected_class??"btn-primary",this.selected_class=t.selected_class??"btn-success"}get selected(){return this.elem.hasClass(this.selected_class)}set selected(t){t?this.elem.removeClass(this.unselected_class).addClass(this.selected_class):this.elem.removeClass(this.selected_class).addClass(this.unselected_class)}},t.ButtonProperty=b,t.CSSProperty=x,t.ChangeListener=_t,t.ClickListener=dt,t.Clock=ot,t.ClockFrameEvent=it,t.ClockIntervalEvent=at,t.ClockTimeoutEvent=rt,t.Collapsible=class{constructor(t){if(!t.elem)throw"No element given";this.elem=t.elem}get collapsed(){return!this.elem.hasClass("show")}set collapsed(t){t?this.elem.collapse("hide"):this.elem.collapse("show")}},t.DataProperty=class extends v{constructor(t,e,s){s?s.ctx=void 0!==s.ctx?s.ctx:t.data:s={ctx:t.data},s.ctxa="data",super(t,e,s)}set(t){this.ctx[this.tgt]=t,super.set(t)}},t.Dialog=M,t.DnD=lt,t.Events=s,t.Form=class{static initialize(t,e,s){const n=i(`#form-${s}`,t,!0);if(!n)return;const r=new e({name:s,elem:n});n.data(s,r)}static instance(t){return e(`#form-${t}`).data(t)}constructor(t){this.name=t.name,this.elem=t.elem}set_field_visibility(t,e){for(const s of t)s.visible=e}},t.FormCheckbox=bt,t.FormField=class extends ft{constructor(t){t.elem=vt(t,"#field"),super(t),this.form=t.form,this.name=t.name;let e=t.input;!e||e instanceof xt||(e=new e({form:this.form,name:this.name})),this.input=e}get has_error(){return this.elem.hasClass("has-error")}set has_error(t){const e=this.elem;t?e.addClass("has-error"):e.removeClass("has-error")}reset(t=""){this.input.value=t,this.has_error=!1,e(".help-block",this.elem).remove()}},t.FormInput=xt,t.FormRemoteSelect=class extends wt{constructor(t){super(t),this.vocab=t.vocab}fetch(t){V({type:"json",url:this.vocab,params:t,success:function(t,e,s){this.options=t}.bind(this)})}},t.FormSelect=wt,t.HTMLParser=$,t.HTMLWidget=gt,t.HTTPRequest=H,t.InputProperty=w,t.KeyState=class extends s{constructor(t){super(),this.filter_keyevent=t,this._keys=[],this._add_key("ctrl",17),this._add_key("shift",16),this._add_key("alt",18),this._add_key("enter",13),this._add_key("esc",27),this._add_key("delete",46),this.bind()}unload(){e(window).off("keydown",this._on_dom_keydown).off("keyup",this._on_dom_keyup)}bind(){this._on_dom_keydown=this._on_dom_keydown.bind(this),this._on_dom_keyup=this._on_dom_keyup.bind(this),e(window).on("keydown",this._on_dom_keydown).on("keyup",this._on_dom_keyup)}_add_key(t,e){this._keys.push(t),this[`_${t}`]=!1,Object.defineProperty(this,t,{get:function(){return this[`_${t}`]},set:function(s){const n=this[`_${t}`];"keydown"===s.type?n||s.keyCode!==e||(this[`_${t}`]=!0):n&&s.keyCode===e&&(this[`_${t}`]=!1)}})}_set_keys(t){for(const e of this._keys)this[e]=t}_filter_event(t){return this.filter_keyevent?.(t)}_on_dom_keydown(t){this._set_keys(t),this._filter_event(t)||this.trigger("keydown",t)}_on_dom_keyup(t){this._set_keys(t),this._filter_event(t)||this.trigger("keyup",t)}},t.LoadingSpinner=G,t.Message=P,t.Motion=pt,t.Overlay=O,t.Parser=y,t.Property=f,t.SVGContext=class extends gt{constructor(t){const e=t.parent.elem.get(0);t.elem=m("svg",{class:t.name},e),super(t),this.svg_ns=u,this.xyz={x:0,y:0,z:1}}svg_attrs(t,e){p(t,e)}svg_elem(t,e,s){return m(t,e,s)}},t.SVGParser=A,t.SVGProperty=class extends v{set(t){const e={};e[this._name]=t,p(this.ctx,e),super.set(t)}},t.TemplateParser=j,t.TextProperty=class extends v{set(t){this.ctx.text(t),super.set(t)}},t.Visibility=ft,t.WS_STATE_CLOSED=3,t.WS_STATE_CLOSING=2,t.WS_STATE_CONNECTING=0,t.WS_STATE_OPEN=1,t.Websocket=class extends s{constructor(t,e=WebSocket){super(),this._ws_factory=e,this.path=t,this.on_open=this.on_open.bind(this),this.on_close=this.on_close.bind(this),this.on_error=this.on_error.bind(this),this.on_message=this.on_message.bind(this),this.on_raw_message=this.on_raw_message.bind(this),this.sock=null}get uri(){let t;return t="http:"===window.location.protocol?"ws://":"wss://",t+window.location.hostname+this.path}get state(){return this.sock.readyState}open(){null!==this.sock&&this.sock.close(),this.sock=new this._ws_factory(this.uri);const t=this.sock;t.onopen=function(){this.trigger("on_open")}.bind(this),t.onclose=function(t){this.trigger("on_close",t)}.bind(this),t.onerror=function(){this.trigger("on_error")}.bind(this),t.onmessage=function(t){this.trigger("on_raw_message",t)}.bind(this)}send(t){this.sock.send(t)}send_json(t){this.sock.send(JSON.stringify(t))}close(){null!==this.sock&&(this.sock.close(),this.sock=null)}on_open(){}on_close(t){}on_error(){}on_message(t){}on_raw_message(t){const e=JSON.parse(t.data);void 0===e.HEARTBEAT&&this.trigger("on_message",e)}},t.Widget=mt,t.ajax=st,t.ajax_destroy=S,t.changeListener=ut,t.clickListener=t=>ct("click",t),t.clock=ht,t.compile_svg=function(t,e,s){const n=g(e,s),i=new A(t);return n.forEach(((t,e)=>{i.walk(t)})),n},t.compile_template=C,t.create_cookie=function(t,e,s){let n,i;s?(n=new Date,n.setTime(n.getTime()+24*s*60*60*1e3),i=`; expires=${n.toGMTString()}`):i="",document.cookie=`${t}=${escape(e)}${i}; path=/;`},t.create_listener=ct,t.create_svg_elem=m,t.deprecate=n,t.extract_number=k,t.get_elem=r,t.get_overlay=q,t.http_request=V,t.json_merge=function(t,e){const s={};for(const n of[t,e])for(const t in n)s[t]=n[t];return s},t.load_svg=function(t,s){e.get(t,(t=>{const n=e(t).find("svg");n.removeAttr("xmlns:a"),s(n)}).bind(this),"xml")},t.lookup_form_elem=vt,t.object_by_path=function(t){if(!t)return null;let e=window;for(const s of t.split("."))if(e=e[s],void 0===e)throw`Object by path not exists: ${t}`;return e},t.parse_path=_,t.parse_query=d,t.parse_svg=g,t.parse_url=c,t.query_elem=i,t.read_cookie=function(t){let e,s,n=`${t}=`,i=document.cookie.split(";");for(e=0;e-1?E.splice(e,1):console.warn("Warning: Ajax destroy handle is not registered and cannot be unregistered: "+t)},t.uuid4=o,Object.defineProperty(t,"__esModule",{value:!0}),window.treibstoff=t,window.bdajax=t.ajax,e.fn.bdajax=e.fn.tsajax,t}({},jQuery); diff --git a/treibstoff/bundle/treibstoff.css b/treibstoff/bundle/treibstoff.css index e49eb7e..72131a2 100644 --- a/treibstoff/bundle/treibstoff.css +++ b/treibstoff/bundle/treibstoff.css @@ -7,15 +7,8 @@ margin-top:-32px; margin-left:-32px; } -.modal-footer button.close { - background-image:linear-gradient(to bottom, #fff 0%, #e0e0e0 100%); - background-repeat:repeat-x; - border:#ccc 1px solid; - padding:6px 12px; - font-size:14px; - font-weight:normal; - line-height:1.42857; - opacity:1; +.modal-footer:empty { + display: none; } .modal.info .modal-body, .modal.warning .modal-body, @@ -28,27 +21,25 @@ .modal.warning .modal-body:before, .modal.error .modal-body:before, .modal.dialog .modal-body:before { - top:2px; - position:relative; - display:inline-block; - font-family:'Glyphicons Halflings'; - font-style:normal; - font-weight:400; - width:60px; - height:60px; - font-size:46px; - line-height:46px; - float:left; + font-family: 'bootstrap-icons'!important; + font-size: 40px; + line-height: 40px; + float: left; + margin-right: 1rem; } .modal.info .modal-body:before { - content:""; + content:"\F430"; + color: var(--bs-cyan); } .modal.warning .modal-body:before { - content:""; + content:"\F46C"; + color: var(--bs-orange); } .modal.error .modal-body:before { - content:""; + content:"\F33A"; + color: var(--bs-red); } .modal.dialog .modal-body:before { - content:"\e085"; + content:"\F505"; + color: var(--bs-secondary); }