juxscript 1.0.102 → 1.0.103

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,369 +0,0 @@
1
- import { BaseSkin } from '../base/BaseSkin.js';
2
- import { ListEngine, ListState, ListItem } from './engine.js';
3
- import structureCss from './structure.css';
4
-
5
-
6
- export class ListSkin extends BaseSkin<ListState, ListEngine> {
7
- // UI Feature Flags
8
- #showSearch = false;
9
- #showAdd = false;
10
- #showDelete = false;
11
- #showEdit = false;
12
- #showMove = false;
13
- #showSort = false;
14
-
15
- // Internal Drag State
16
- #dragSourceIndex: number | null = null;
17
-
18
- // DOM References (Stable elements to prevent re-render thrashing)
19
- #toolbar: HTMLElement | null = null;
20
- #listContainer: HTMLElement | null = null;
21
-
22
- // Toolbar Componenets
23
- #searchInput: HTMLInputElement | null = null;
24
- #addBtn: HTMLButtonElement | null = null;
25
- #sortBtn: HTMLButtonElement | null = null;
26
-
27
- constructor(engine: ListEngine) {
28
- super(engine);
29
- }
30
-
31
- // --- Public UI API ---
32
- enableSearch(enabled: boolean): void { this.#showSearch = enabled; this.#renderToolbar(); }
33
- enableAdd(enabled: boolean): void { this.#showAdd = enabled; this.#renderToolbar(); }
34
- enableDelete(enabled: boolean): void { this.#showDelete = enabled; this.updateSkin(this.engine.state); }
35
- enableMove(enabled: boolean): void { this.#showMove = enabled; this.updateSkin(this.engine.state); }
36
- enableSort(enabled: boolean): void { this.#showSort = enabled; this.#renderToolbar(); }
37
- enableNoItems(message: string = 'No items found.'): void { this.engine.enableNoItems(message); }
38
-
39
- enableEdit(enabled: boolean): void {
40
- this.#showEdit = enabled;
41
- this.updateSkin(this.engine.state);
42
- }
43
- protected get structureCss(): string {
44
- return structureCss;
45
- }
46
- // --- Template Skin Implementation ---
47
-
48
- protected bindEvents(root: HTMLElement): void {
49
- // 1. Delegation: Toolbar Events (Handled via direct element ref where possible now, or delegation fallback)
50
-
51
- // We attach the input listener globally on root for delegation,
52
- // but now that we hold the #searchInput ref, we could bind directly.
53
- // Delegation is still safer if elements are recreated.
54
- root.addEventListener('input', (e: Event) => {
55
- const target = e.target as HTMLInputElement;
56
- if (target.matches('.jux-search-input')) {
57
- this.engine.filter(target.value);
58
- }
59
- });
60
-
61
- root.addEventListener('click', (e: MouseEvent) => {
62
- const target = e.target as HTMLElement;
63
-
64
- // Toolbar: Add
65
- if (target.closest('.jux-action-add')) {
66
- this.engine.addItem('New Item');
67
- const newIndex = this.engine.state.items.length - 1;
68
- if (newIndex >= 0) this.#openEditModal(newIndex);
69
- return;
70
- }
71
-
72
- // Toolbar: Sort
73
- if (target.closest('.jux-action-sort')) {
74
- this.engine.toggleSort();
75
- return;
76
- }
77
-
78
- // List Item Actions
79
- const li = target.closest('li');
80
- if (!li) return;
81
-
82
- // ✅ USE ID for robust targeting
83
- const id = li.dataset.id;
84
- const indexStr = li.dataset.index;
85
- if (!indexStr || !id) return;
86
-
87
- const index = parseInt(indexStr, 10);
88
-
89
- // Action: Delete
90
- if (target.closest('.jux-action-delete')) {
91
- this.engine.removeItem(id); // ✅ Valid per new engine signature
92
- e.stopImmediatePropagation();
93
- return;
94
- }
95
-
96
- // Action: Edit
97
- if (target.closest('.jux-action-edit')) {
98
- this.#openEditModal(index);
99
- e.stopImmediatePropagation();
100
- return;
101
- }
102
-
103
- // List Item: Selection
104
- if (!target.closest('.jux-control')) {
105
- this.engine.toggleSelection(index);
106
- }
107
- });
108
-
109
- // 2. Drag & Drop Logic
110
- root.addEventListener('dragstart', (e: DragEvent) => {
111
- if (!this.#showMove) return;
112
- const li = (e.target as HTMLElement).closest('li');
113
- if (li) {
114
- this.#dragSourceIndex = parseInt(li.dataset.index || '-1', 10);
115
- e.dataTransfer!.effectAllowed = 'move';
116
- e.dataTransfer!.setData('text/plain', li.dataset.index || '');
117
- requestAnimationFrame(() => li.classList.add('jux-dragging'));
118
- }
119
- });
120
-
121
- root.addEventListener('dragend', (e: DragEvent) => {
122
- const li = (e.target as HTMLElement).closest('li');
123
- if (li) li.classList.remove('jux-dragging');
124
- root.querySelectorAll('.jux-item-drop-target').forEach(el => el.classList.remove('jux-item-drop-target'));
125
- this.#dragSourceIndex = null;
126
- });
127
-
128
- root.addEventListener('dragover', (e: DragEvent) => {
129
- if (!this.#showMove) return;
130
- e.preventDefault();
131
- const li = (e.target as HTMLElement).closest('li');
132
- if (li && !li.classList.contains('jux-dragging')) {
133
- e.dataTransfer!.dropEffect = 'move';
134
- root.querySelectorAll('.jux-item-drop-target').forEach(el => el.classList.remove('jux-item-drop-target'));
135
- li.classList.add('jux-item-drop-target');
136
- }
137
- });
138
-
139
- root.addEventListener('drop', (e: DragEvent) => {
140
- if (!this.#showMove) return;
141
- e.preventDefault();
142
- const li = (e.target as HTMLElement).closest('li');
143
- if (li && this.#dragSourceIndex !== null) {
144
- const toIndex = parseInt(li.dataset.index || '-1', 10);
145
- if (toIndex !== -1 && this.#dragSourceIndex !== toIndex) {
146
- this.engine.moveItem(this.#dragSourceIndex, toIndex);
147
- }
148
- }
149
- });
150
- }
151
-
152
- // --- Internals: Modal Editing ---
153
- #openEditModal(index: number): void {
154
- const item = this.engine.state.items[index];
155
- if (!item) return;
156
-
157
- const overlay = document.createElement('div');
158
- overlay.className = 'jux-modal-overlay';
159
- overlay.innerHTML = `
160
- <div class="jux-modal">
161
- <h3>Edit Item</h3>
162
- <input type="text" id="jux-edit-input" value="${item.text.replace(/"/g, '&quot;')}" />
163
- <div class="jux-modal-actions">
164
- <button class="jux-modal-btn jux-btn-cancel" id="jux-edit-cancel">Cancel</button>
165
- <button class="jux-modal-btn jux-btn-save" id="jux-edit-save">Save</button>
166
- </div>
167
- </div>
168
- `;
169
- document.body.appendChild(overlay);
170
- const input = overlay.querySelector('#jux-edit-input') as HTMLInputElement;
171
- const close = () => document.body.removeChild(overlay);
172
- const save = () => {
173
- if (input.value.trim()) this.engine.updateItem(index, input.value);
174
- close();
175
- };
176
-
177
- overlay.querySelector('#jux-edit-cancel')?.addEventListener('click', close);
178
- overlay.querySelector('#jux-edit-save')?.addEventListener('click', save);
179
- overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
180
- input.addEventListener('keydown', (e) => {
181
- if (e.key === 'Enter') save();
182
- if (e.key === 'Escape') close();
183
- });
184
- setTimeout(() => { input.focus(); input.select(); }, 0);
185
- }
186
-
187
- /**
188
- * Skin Contract: Update/Changes
189
- */
190
- protected updateSkin(state: ListState): void {
191
- if (!this.root) return;
192
-
193
- // ✅ Ensure standard ID is set
194
- this.root.id = state.id;
195
-
196
- // Perform the DOM Update
197
- const performUpdate = () => {
198
- this.applySkinAttributes(this.root!, state);
199
-
200
- // Lazy Creation
201
- if (!this.#listContainer) {
202
- this.root!.innerHTML = '';
203
- this.#toolbar = document.createElement('div');
204
- this.#toolbar.className = 'jux-list-toolbar';
205
- this.root!.appendChild(this.#toolbar);
206
-
207
- this.#listContainer = document.createElement('div');
208
- this.root!.appendChild(this.#listContainer);
209
- this.#renderToolbar(); // Initial render
210
- }
211
-
212
- // Always update toolbar state (e.g. icon direction) without destroying inputs
213
- this.#renderToolbar();
214
-
215
- // Container Classes
216
- const classes = [...state.classes];
217
- if (state.selectionMode !== 'none') classes.push('jux-list-selectable');
218
- this.#listContainer!.className = classes.join(' ');
219
-
220
- // Filtering
221
- const visibleItems = state.items
222
- .map((item, originalIndex) => ({ item, originalIndex }))
223
- .filter(({ item }) =>
224
- state.filterText
225
- ? item.text.toLowerCase().includes(state.filterText.toLowerCase())
226
- : true
227
- );
228
-
229
- // Rendering Content
230
- if (visibleItems.length === 0 && state.noItemsMessage) {
231
- this.#listContainer!.innerHTML = `<div class="jux-list-empty-state">${state.noItemsMessage}</div>`;
232
- } else {
233
- const tag = state.listType === 'ordered' ? 'ol' : 'ul';
234
- const html = visibleItems
235
- .map(({ item, originalIndex }) => this.#renderListItem(item, originalIndex, state))
236
- .join('');
237
-
238
- this.#listContainer!.innerHTML = `<${tag} class="jux-list-element jux-list-type-${tag}">${html}</${tag}>`;
239
- }
240
- };
241
-
242
- // ✨ View Transitions Logic
243
- // We SKIP transition if the search input is focused, otherwise typing janks the UI.
244
- const isSearching = document.activeElement &&
245
- (document.activeElement === this.#searchInput);
246
-
247
- if (!isSearching && typeof document !== 'undefined' && 'startViewTransition' in document) {
248
- // @ts-ignore
249
- document.startViewTransition(performUpdate);
250
- } else {
251
- performUpdate();
252
- }
253
- }
254
-
255
- // --- DOM-Based Toolbar Rendering ---
256
- #renderToolbar(): void {
257
- if (!this.#toolbar) return;
258
-
259
- // 1. Search Logic
260
- if (this.#showSearch) {
261
- if (!this.#searchInput) {
262
- this.#searchInput = document.createElement('input');
263
- this.#searchInput.type = 'text';
264
- this.#searchInput.className = 'jux-search-input';
265
- this.#searchInput.placeholder = 'Filter items...';
266
- // Insert at start
267
- this.#toolbar.prepend(this.#searchInput);
268
- }
269
- } else if (this.#searchInput) {
270
- this.#searchInput.remove();
271
- this.#searchInput = null;
272
- }
273
-
274
- // 2. Sort Logic
275
- if (this.#showSort) {
276
- if (!this.#sortBtn) {
277
- this.#sortBtn = document.createElement('button');
278
- this.#sortBtn.className = 'jux-action-sort';
279
- this.#sortBtn.type = 'button';
280
- this.#sortBtn.ariaLabel = 'Sort';
281
- this.#toolbar.appendChild(this.#sortBtn);
282
- }
283
- // Update Icon state
284
- const s = this.engine.state.sorted;
285
- const icon = s === 'asc' ? '↑' : (s === 'desc' ? '↓' : '↕');
286
- if (this.#sortBtn.textContent !== icon) {
287
- this.#sortBtn.textContent = icon;
288
- }
289
- } else if (this.#sortBtn) {
290
- this.#sortBtn.remove();
291
- this.#sortBtn = null;
292
- }
293
-
294
- // 3. Add Logic
295
- if (this.#showAdd) {
296
- if (!this.#addBtn) {
297
- this.#addBtn = document.createElement('button');
298
- this.#addBtn.className = 'jux-action-add';
299
- this.#addBtn.type = 'button';
300
- this.#addBtn.ariaLabel = 'Add Item';
301
- this.#addBtn.textContent = '+';
302
- this.#toolbar.appendChild(this.#addBtn);
303
- }
304
- } else if (this.#addBtn) {
305
- this.#addBtn.remove();
306
- this.#addBtn = null;
307
- }
308
-
309
- // Parent Visibility
310
- const hasChildren = this.#showSearch || this.#showSort || this.#showAdd;
311
- this.#toolbar.style.display = hasChildren ? 'flex' : 'none';
312
-
313
- // ensure Search input is first by re-prepending if it exists
314
- if (this.#searchInput && this.#toolbar.firstChild !== this.#searchInput) {
315
- this.#toolbar.prepend(this.#searchInput);
316
- }
317
- }
318
-
319
- #renderListItem(item: ListItem, index: number, state: ListState): string {
320
- const css = [
321
- 'jux-list-item',
322
- item.selected ? 'jux-selected' : '',
323
- ...(item.classes || [])
324
- ].filter(Boolean).join(' ');
325
-
326
- // ✅ Determine Content based on Columns configuration
327
- let content = item.text;
328
-
329
- if (state.columns && state.columns.length > 0) {
330
- content = state.columns.map(col => {
331
- const key = typeof col === 'string' ? col : col.key;
332
- const cssClass = (typeof col === 'object' && col.label) ? `jux-col-${key}` : ''; // Basic class hook
333
- const value = item[key] ?? '';
334
- // If complex object, stringify or fallback
335
- const displayVal = (typeof value === 'object') ? JSON.stringify(value) : String(value);
336
-
337
- return `<span class="jux-col ${cssClass}">${displayVal}</span>`;
338
- }).join(' <span class="jux-col-sep"> </span> ');
339
- } else {
340
- // Default Fallback logic: If no columns but item.text is boring ("[object Object]"), try to find keys
341
- if (content === '[object Object]') {
342
- const keys = Object.keys(item).filter(k =>
343
- k !== 'id' && k !== 'selected' && k !== 'classes' && k !== 'text' && k !== 'value'
344
- );
345
- if (keys.length > 0) {
346
- content = `${item[keys[0]]}`;
347
- }
348
- }
349
- }
350
-
351
- const moveBtn = this.#showMove ? `<span class="jux-action-drag jux-control" title="Drag to reorder">≡</span>` : '';
352
- const editBtn = this.#showEdit ? `<button class="jux-action-edit jux-control" aria-label="Edit">✎</button>` : '';
353
- const deleteBtn = this.#showDelete ? `<button class="jux-action-delete jux-control" aria-label="Delete">×</button>` : '';
354
- const draggableAttr = this.#showMove ? 'draggable="true"' : '';
355
- const transitionName = `jux-item-${item.id.replace(/[^a-zA-Z0-9-_]/g, '-')}`;
356
-
357
- return `
358
- <li data-id="${item.id}" data-index="${index}" class="${css}" ${draggableAttr} style="view-transition-name: ${transitionName}">
359
- <div class="jux-list-item-content">
360
- ${content}
361
- </div>
362
- <div class="jux-item-controls">
363
- ${moveBtn}
364
- ${editBtn}
365
- ${deleteBtn}
366
- </div>
367
- </li>`;
368
- }
369
- }
@@ -1,154 +0,0 @@
1
- import { JuxServiceContract, BaseEngine } from '../base/BaseEngine.js';
2
-
3
- export interface ClientSQLiteConfig {
4
- /** Key used for IndexedDB storage (the "filename") */
5
- dbName: string;
6
-
7
- /** SQL to run if creating a brand new DB (Schema) */
8
- initSql?: string;
9
-
10
- /** Main Query to bind to the Engine State */
11
- query?: string;
12
-
13
- /** State property to bind results to (e.g. 'items') */
14
- bindTo?: string;
15
-
16
- /** Transform row object to State Item shape */
17
- mapRow?: (row: any) => any;
18
-
19
- /** If true, persists to IDB after every write operation */
20
- autoSave?: boolean;
21
- }
22
-
23
- /**
24
- * Client-Side SQLite Plugin
25
- * Runs a real SQLite engine via WebAssembly (WASM) in the browser, persisted to IndexedDB.
26
- */
27
- export const ClientSQLitePlugin = (config: ClientSQLiteConfig): JuxServiceContract<BaseEngine<any>> => ({
28
- name: 'client-sqlite-wasm',
29
- version: '1.0.0',
30
- targetEnv: 'client',
31
-
32
- install: (engine) => {
33
- let db: any = null;
34
- const IDB_NAME = 'JuxSqliteStorage';
35
- const IDB_STORE = 'files';
36
-
37
- // --- 1. IndexedDB HELPER (The "Hard Drive") ---
38
- const getFile = (key: string): Promise<Uint8Array | null> => {
39
- return new Promise((resolve) => {
40
- const r = indexedDB.open(IDB_NAME, 1);
41
- r.onupgradeneeded = (e: any) => e.target.result.createObjectStore(IDB_STORE);
42
- r.onsuccess = (e: any) => {
43
- const t = e.target.result.transaction([IDB_STORE], 'readonly').objectStore(IDB_STORE).get(key);
44
- t.onsuccess = () => resolve(t.result);
45
- t.onerror = () => resolve(null);
46
- };
47
- });
48
- };
49
-
50
- const saveFile = (key: string, data: Uint8Array) => {
51
- const r = indexedDB.open(IDB_NAME, 1);
52
- r.onsuccess = (e: any) => {
53
- const tx = e.target.result.transaction([IDB_STORE], 'readwrite');
54
- tx.objectStore(IDB_STORE).put(data, key);
55
- };
56
- };
57
-
58
- // --- 2. SQL Helper (Result Transformation) ---
59
- // sql.js return format: [{ columns: ['id', 'name'], values: [[1, 'Bob']] }]
60
- // We convert this to: [{ id: 1, name: 'Bob' }]
61
- const normalizeResults = (res: any[]) => {
62
- if (!res || !res.length) return [];
63
- const columns = res[0].columns;
64
- const values = res[0].values;
65
- return values.map((row: any[]) => {
66
- const obj: any = {};
67
- columns.forEach((col: string, i: number) => {
68
- obj[col] = row[i];
69
- });
70
- return obj;
71
- });
72
- };
73
-
74
- // --- 3. REFRESH LOGIC ---
75
- const refresh = () => {
76
- if (!db || !config.query || !config.bindTo) return;
77
- try {
78
- // @ts-ignore
79
- if (engine.loading) engine.loading(true);
80
-
81
- // Run Query
82
- const raw = db.exec(config.query);
83
- const rows = normalizeResults(raw);
84
-
85
- // Map & Update State
86
- const items = config.mapRow ? rows.map(config.mapRow) : rows;
87
- // @ts-ignore
88
- engine.updateState({ [config.bindTo]: items });
89
-
90
- // @ts-ignore
91
- if (engine.loading) engine.loading(false);
92
- // @ts-ignore
93
- if (engine.emit) engine.emit('sql:refresh', { count: items.length });
94
-
95
- } catch (e) {
96
- console.error('[ClientSQLite] Query Error:', e);
97
- // @ts-ignore
98
- if (engine.loading) engine.loading(false);
99
- }
100
- };
101
-
102
- // --- 4. INITIALIZATION ---
103
- const loadSqlJs = async () => {
104
- // Lazy load sql.js from CDN if not present
105
- if (!(window as any).initSqlJs) {
106
- await new Promise(resolve => {
107
- const script = document.createElement('script');
108
- // sql-wasm.js is the glue code that loads the .wasm binary
109
- script.src = 'https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.8.0/sql-wasm.js';
110
- script.onload = resolve;
111
- document.head.appendChild(script);
112
- });
113
- }
114
-
115
- const SQL = await (window as any).initSqlJs({
116
- locateFile: (file: string) => `https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.8.0/${file}`
117
- });
118
-
119
- // "Mount" the file from IDB
120
- const binary = await getFile(config.dbName);
121
-
122
- if (binary) {
123
- db = new SQL.Database(binary);
124
- // @ts-ignore
125
- if (engine.emit) engine.emit('sql:mounted', { size: binary.length });
126
- } else {
127
- db = new SQL.Database();
128
- if (config.initSql) db.run(config.initSql);
129
- if (config.autoSave) saveFile(config.dbName, db.export());
130
- // @ts-ignore
131
- if (engine.emit) engine.emit('sql:created');
132
- }
133
-
134
- // Expose SQL methods to Engine (Mixins)
135
- Object.assign(engine, {
136
- sqlRun: (sql: string) => {
137
- db.run(sql);
138
- if (config.autoSave) saveFile(config.dbName, db.export());
139
- refresh();
140
- },
141
- sqlExec: (sql: string) => {
142
- return normalizeResults(db.exec(sql));
143
- },
144
- sqlExport: () => {
145
- return db.export();
146
- }
147
- });
148
-
149
- refresh();
150
- };
151
-
152
- loadSqlJs();
153
- }
154
- });
@@ -1,96 +0,0 @@
1
- import { JuxServiceContract, BaseEngine } from '../base/BaseEngine.js';
2
-
3
- export interface IndexedDBConfig {
4
- dbName: string;
5
- storeName: string;
6
-
7
- /**
8
- * State Binding: The property key on the Engine's state to persist.
9
- * e.g., 'items'
10
- */
11
- bindTo: string;
12
-
13
- /** If true, loads data immediately on install */
14
- autoLoad?: boolean;
15
- }
16
-
17
- /**
18
- * IndexedDB Plugin
19
- * Asynchronous persistence for larger datasets.
20
- */
21
- export const IndexedDBPlugin = (config: IndexedDBConfig): JuxServiceContract<BaseEngine<any>> => ({
22
- name: 'indexed-db-persist',
23
- version: '1.0.0',
24
- targetEnv: 'client',
25
-
26
- install: (engine) => {
27
-
28
- // @ts-ignore
29
- const initialState = engine.state[config.bindTo];
30
- if (initialState !== undefined && !Array.isArray(initialState)) {
31
- console.error(`[IndexedDBPlugin] 🛑 Configuration Error: bindTo='${config.bindTo}' is not an Array. This plugin is designed for List/Collection data.`);
32
- return;
33
- }
34
-
35
- let db: IDBDatabase | null = null;
36
- const request = indexedDB.open(config.dbName, 1);
37
-
38
- // 1. Schema Setup
39
- request.onupgradeneeded = (e: any) => {
40
- const d = e.target.result;
41
- if (!d.objectStoreNames.contains(config.storeName)) {
42
- d.createObjectStore(config.storeName);
43
- }
44
- };
45
-
46
- // 2. IO Operations
47
- const save = (data: any) => {
48
- if (!db) return;
49
- const tx = db.transaction([config.storeName], 'readwrite');
50
- const store = tx.objectStore(config.storeName);
51
- store.put(data, 'state_snapshot'); // Storing entire bound state as one blob for simplicity
52
- };
53
-
54
- const load = () => {
55
- if (!db) return;
56
- // @ts-ignore
57
- if (typeof engine.loading === 'function') engine.loading(true);
58
-
59
- const tx = db.transaction([config.storeName], 'readonly');
60
- const store = tx.objectStore(config.storeName);
61
- const req = store.get('state_snapshot');
62
-
63
- req.onsuccess = () => {
64
- if (req.result) {
65
- // @ts-ignore
66
- engine.updateState({ [config.bindTo]: req.result });
67
- // @ts-ignore
68
- if (engine.emit) engine.emit('plugin:hydrated', { source: 'IndexedDB' });
69
- }
70
- // @ts-ignore
71
- if (typeof engine.loading === 'function') engine.loading(false);
72
- };
73
-
74
- req.onerror = () => {
75
- // @ts-ignore
76
- if (typeof engine.loading === 'function') engine.loading(false);
77
- };
78
- };
79
-
80
- // 3. Initialization
81
- request.onsuccess = (e: any) => {
82
- db = e.target.result;
83
-
84
- if (config.autoLoad) {
85
- load();
86
- }
87
-
88
- // Auto-save subscription (debounced slightly in real world, direct here)
89
- engine.subscribe((state) => {
90
- if (db) save(state[config.bindTo]);
91
- });
92
- };
93
-
94
- request.onerror = (e) => console.error('[IndexedDBPlugin] Open Error', e);
95
- }
96
- });